Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b3d9dc2c | |||
| d6d93bddd9 | |||
| 3e6c8d685b | |||
| 236baed80d | |||
| fd28cb8a93 | |||
| cb0e5596ea | |||
| 7532446e46 | |||
| 320b842c6e | |||
| 47f073539a | |||
| 36c37c8e67 | |||
| e7d0395be0 | |||
| 2e45d7ea5a | |||
| 0a5e2b94e2 | |||
| 1dec76ff0c | |||
| 60c243a733 | |||
| 696e369ec1 | |||
| 93b28a851e | |||
| cb28cb12cd | |||
| d8376d6cdf | |||
| 543bd2b464 | |||
| 32bb72d12d | |||
| 4f92b4e508 | |||
| 53bf4a3187 | |||
| 87267d8e80 | |||
| 11e50c54bb | |||
| 377ae2d39e | |||
| a60ba86b19 | |||
| 71a102028d | |||
| 8858c81f87 | |||
| 5b1fb1584e | |||
| e6328a1e8d | |||
| 8582a3eac5 | |||
| 37d3d2a5b3 | |||
| ce108475a5 | |||
| 979ac9823f | |||
| 2fb7d10e39 | |||
| 6d4284c6c9 | |||
| 57333482e3 | |||
| 3ac54da149 | |||
| 2af8a72ca3 | |||
| 740fb4e1f6 | |||
| 1413f62476 | |||
| d6fb2816cf | |||
| 464514bc37 |
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||||
# VERSION: 05.00.00
|
# VERSION: 05.01.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +=======================================================================+
|
# +=======================================================================+
|
||||||
@@ -75,6 +75,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
@@ -173,6 +174,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Configure git for bot pushes
|
- name: Configure git for bot pushes
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.05.00
|
# VERSION: 01.07.03
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
+33
-7
@@ -1,16 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [01.05.00] --- 2026-06-28
|
|
||||||
|
|
||||||
|
|
||||||
<!-- VERSION: 01.05.00 -->
|
|
||||||
|
|
||||||
All notable changes to MokoSuiteOpenGraph will be documented in this file.
|
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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
<!-- VERSION: 01.07.03 -->
|
||||||
|
|
||||||
|
## [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
|
## [01.05.00] --- 2026-06-28
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: Template-Joomla
|
DEFGROUP: Template-Joomla
|
||||||
INGROUP: Template-Joomla.Documentation
|
INGROUP: Template-Joomla.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
|
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
|
||||||
VERSION: 01.05.00
|
VERSION: 01.07.03
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Community expectations and enforcement guidelines
|
BRIEF: Community expectations and enforcement guidelines
|
||||||
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.Template-Joomla
|
DEFGROUP: mokoconsulting-tech.Template-Joomla
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
|
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
|
||||||
VERSION: 01.05.00
|
VERSION: 01.07.03
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
|
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MokoSuiteOpenGraph
|
# MokoSuiteOpenGraph
|
||||||
|
|
||||||
<!-- VERSION: 01.05.00 -->
|
<!-- VERSION: 01.07.03 -->
|
||||||
|
|
||||||
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher.
|
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
|
- **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
|
- **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
|
- **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields
|
||||||
- **OG coverage dashboard** — Coverage percentage and missing field counts
|
- **Coverage dashboard** — Default admin view: coverage donut, breakdown by content type, and a list of articles missing OG tags with quick batch-generate
|
||||||
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI
|
- **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
|
### Developer Features
|
||||||
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
|
- **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
|
- **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
|
- **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, 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 and public access levels, written atomically
|
||||||
- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex
|
|
||||||
- **OpenAPI spec** — Full REST API documentation at `openapi.yaml`
|
- **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
|
## 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)
|
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
|
2. In Joomla Administrator → Extensions → Install → Upload Package File
|
||||||
3. All plugins are enabled automatically on install
|
3. All plugins are enabled automatically on install
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
|
|||||||
INGROUP: Template-Joomla.Documentation
|
INGROUP: Template-Joomla.Documentation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 01.05.00
|
VERSION: 01.07.03
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -47,7 +47,7 @@ class TagsController extends ApiController
|
|||||||
throw new \RuntimeException('content_type and content_id are required', 400);
|
throw new \RuntimeException('content_type and content_id are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('id'))
|
->select($db->quoteName('id'))
|
||||||
->from($db->quoteName('#__mokoog_tags'))
|
->from($db->quoteName('#__mokoog_tags'))
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ class JsonapiView extends BaseApiView
|
|||||||
'og_description',
|
'og_description',
|
||||||
'og_image',
|
'og_image',
|
||||||
'og_type',
|
'og_type',
|
||||||
|
'og_video',
|
||||||
'seo_title',
|
'seo_title',
|
||||||
'meta_description',
|
'meta_description',
|
||||||
'robots',
|
'robots',
|
||||||
'canonical_url',
|
'canonical_url',
|
||||||
|
'event_data',
|
||||||
|
'recipe_data',
|
||||||
|
'custom_schema',
|
||||||
'language',
|
'language',
|
||||||
'published',
|
'published',
|
||||||
'created',
|
'created',
|
||||||
@@ -54,10 +58,14 @@ class JsonapiView extends BaseApiView
|
|||||||
'og_description',
|
'og_description',
|
||||||
'og_image',
|
'og_image',
|
||||||
'og_type',
|
'og_type',
|
||||||
|
'og_video',
|
||||||
'seo_title',
|
'seo_title',
|
||||||
'meta_description',
|
'meta_description',
|
||||||
'robots',
|
'robots',
|
||||||
'canonical_url',
|
'canonical_url',
|
||||||
|
'event_data',
|
||||||
|
'recipe_data',
|
||||||
|
'custom_schema',
|
||||||
'language',
|
'language',
|
||||||
'published',
|
'published',
|
||||||
'created',
|
'created',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,13 +16,15 @@
|
|||||||
name="content_type"
|
name="content_type"
|
||||||
type="text"
|
type="text"
|
||||||
label="COM_MOKOOG_FIELD_CONTENT_TYPE"
|
label="COM_MOKOOG_FIELD_CONTENT_TYPE"
|
||||||
readonly="true"
|
description="COM_MOKOOG_FIELD_CONTENT_TYPE_DESC"
|
||||||
|
required="true"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="content_id"
|
name="content_id"
|
||||||
type="number"
|
type="number"
|
||||||
label="COM_MOKOOG_FIELD_CONTENT_ID"
|
label="COM_MOKOOG_FIELD_CONTENT_ID"
|
||||||
readonly="true"
|
description="COM_MOKOOG_FIELD_CONTENT_ID_DESC"
|
||||||
|
required="true"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="og_title"
|
name="og_title"
|
||||||
@@ -77,37 +79,45 @@
|
|||||||
<option value="1">JPUBLISHED</option>
|
<option value="1">JPUBLISHED</option>
|
||||||
<option value="0">JUNPUBLISHED</option>
|
<option value="0">JUNPUBLISHED</option>
|
||||||
</field>
|
</field>
|
||||||
|
<field
|
||||||
|
name="language"
|
||||||
|
type="contentlanguage"
|
||||||
|
label="JFIELD_LANGUAGE_LABEL"
|
||||||
|
default="*"
|
||||||
|
>
|
||||||
|
<option value="*">JALL</option>
|
||||||
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset name="seo" label="SEO Meta Tags">
|
<fieldset name="seo" label="COM_MOKOOG_FIELDSET_SEO">
|
||||||
<field
|
<field
|
||||||
name="seo_title"
|
name="seo_title"
|
||||||
type="text"
|
type="text"
|
||||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
label="COM_MOKOOG_FIELD_SEO_TITLE"
|
||||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
description="COM_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||||
filter="string"
|
filter="string"
|
||||||
maxlength="255"
|
maxlength="70"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="meta_description"
|
name="meta_description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
|
label="COM_MOKOOG_FIELD_META_DESCRIPTION"
|
||||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
description="COM_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||||
filter="string"
|
filter="string"
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="255"
|
maxlength="200"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="robots"
|
name="robots"
|
||||||
type="text"
|
type="text"
|
||||||
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
|
label="COM_MOKOOG_FIELD_ROBOTS"
|
||||||
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
|
description="COM_MOKOOG_FIELD_ROBOTS_DESC"
|
||||||
filter="string"
|
filter="string"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="canonical_url"
|
name="canonical_url"
|
||||||
type="url"
|
type="url"
|
||||||
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
|
label="COM_MOKOOG_FIELD_CANONICAL_URL"
|
||||||
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
|
description="COM_MOKOOG_FIELD_CANONICAL_URL_DESC"
|
||||||
filter="url"
|
filter="url"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
COM_MOKOOG="MokoSuiteOpenGraph"
|
COM_MOKOOG="MokoSuiteOpenGraph"
|
||||||
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
|
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
|
||||||
COM_MOKOOG_SUBMENU_TAGS="Tags"
|
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_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_TABLE_CAPTION="Table of Open Graph tags"
|
||||||
COM_MOKOOG_AUTO_GENERATED="auto-generated"
|
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_TITLE="%d tags missing custom title"
|
||||||
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
||||||
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
|
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."
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
COM_MOKOOG="MokoSuiteOpenGraph"
|
COM_MOKOOG="MokoSuiteOpenGraph"
|
||||||
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
|
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
|
||||||
COM_MOKOOG_SUBMENU_TAGS="Tags"
|
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_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_TABLE_CAPTION="Table of Open Graph tags"
|
||||||
COM_MOKOOG_AUTO_GENERATED="auto-generated"
|
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_TITLE="%d tags missing custom title"
|
||||||
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
||||||
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
|
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."
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<extension type="component" method="upgrade">
|
||||||
<name>com_mokoog</name>
|
<name>com_mokoog</name>
|
||||||
<version>01.05.00</version>
|
<version>01.07.03</version>
|
||||||
<creationDate>2026-05-23</creationDate>
|
<creationDate>2026-05-23</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
@@ -50,6 +50,8 @@
|
|||||||
<folder>View</folder>
|
<folder>View</folder>
|
||||||
</files>
|
</files>
|
||||||
<files folder="tmpl">
|
<files folder="tmpl">
|
||||||
|
<folder>dashboard</folder>
|
||||||
|
<folder>tag</folder>
|
||||||
<folder>tags</folder>
|
<folder>tags</folder>
|
||||||
</files>
|
</files>
|
||||||
<files folder="sql">
|
<files folder="sql">
|
||||||
@@ -63,9 +65,15 @@
|
|||||||
</files>
|
</files>
|
||||||
<files folder="language">
|
<files folder="language">
|
||||||
<folder>en-GB</folder>
|
<folder>en-GB</folder>
|
||||||
|
<folder>en-US</folder>
|
||||||
|
</files>
|
||||||
|
<files>
|
||||||
|
<filename>access.xml</filename>
|
||||||
|
<filename>config.xml</filename>
|
||||||
</files>
|
</files>
|
||||||
<menu img="class:bookmark">COM_MOKOOG</menu>
|
<menu img="class:bookmark">COM_MOKOOG</menu>
|
||||||
<submenu>
|
<submenu>
|
||||||
|
<menu link="option=com_mokoog&view=dashboard">COM_MOKOOG_SUBMENU_DASHBOARD</menu>
|
||||||
<menu link="option=com_mokoog&view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
|
<menu link="option=com_mokoog&view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
|
||||||
</submenu>
|
</submenu>
|
||||||
</administration>
|
</administration>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.04.18 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.05.01 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.05.02 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.00 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.02 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.03 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.04 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.05 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.06 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.07 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.08 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.09 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.10 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.11 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.12 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.06.13 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.07.01 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.07.02 — no schema changes */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 01.07.03 — no schema changes */
|
||||||
@@ -27,13 +27,16 @@ class BatchController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function count(): void
|
public function count(): void
|
||||||
{
|
{
|
||||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
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);
|
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select('COUNT(*)')
|
||||||
->from($db->quoteName('#__content', 'c'))
|
->from($db->quoteName('#__content', 'c'))
|
||||||
@@ -60,16 +63,21 @@ class BatchController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function process(): void
|
public function process(): void
|
||||||
{
|
{
|
||||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
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);
|
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
$app = Factory::getApplication();
|
||||||
$limit = min($app->getInput()->getInt('limit', 50), 200);
|
$input = $app->getInput();
|
||||||
|
$limit = min($input->getInt('limit', 50), 200);
|
||||||
|
$lastId = max(0, $input->getInt('lastid', 0));
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName([
|
->select($db->quoteName([
|
||||||
'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images',
|
'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images',
|
||||||
@@ -82,18 +90,25 @@ class BatchController extends BaseController
|
|||||||
)
|
)
|
||||||
->where($db->quoteName('c.state') . ' = 1')
|
->where($db->quoteName('c.state') . ' = 1')
|
||||||
->where($db->quoteName('t.id') . ' IS NULL')
|
->where($db->quoteName('t.id') . ' IS NULL')
|
||||||
|
->where($db->quoteName('c.id') . ' > ' . $lastId)
|
||||||
->order($db->quoteName('c.id') . ' ASC');
|
->order($db->quoteName('c.id') . ' ASC');
|
||||||
|
|
||||||
// Always offset=0: processed articles now have #__mokoog_tags rows
|
// Cursor-based pagination by id: each chunk fetches the next articles whose
|
||||||
// and are excluded by the LEFT JOIN ... IS NULL filter automatically.
|
// id is greater than the previous chunk's highest id. A row that fails to
|
||||||
|
// insert is passed over on the next chunk (its id is already behind the
|
||||||
|
// cursor) instead of being re-fetched forever, so the batch always reaches
|
||||||
|
// the end. The client stops when a chunk examines 0 rows.
|
||||||
$db->setQuery($query, 0, $limit);
|
$db->setQuery($query, 0, $limit);
|
||||||
$articles = $db->loadObjectList();
|
$articles = $db->loadObjectList();
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$now = Factory::getDate()->toSql();
|
$lastProcessedId = $lastId;
|
||||||
|
$now = Factory::getDate()->toSql();
|
||||||
|
|
||||||
foreach ($articles as $article) {
|
foreach ($articles as $article) {
|
||||||
|
$lastProcessedId = (int) $article->id;
|
||||||
|
|
||||||
$ogTitle = $article->title;
|
$ogTitle = $article->title;
|
||||||
$ogDescription = $this->extractDescription($article);
|
$ogDescription = $this->extractDescription($article);
|
||||||
$ogImage = $this->extractImage($article);
|
$ogImage = $this->extractImage($article);
|
||||||
@@ -125,7 +140,10 @@ class BatchController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo new JsonResponse([
|
echo new JsonResponse([
|
||||||
'created' => $created,
|
'created' => $created,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'examined' => \count($articles),
|
||||||
|
'last_id' => $lastProcessedId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$app->close();
|
$app->close();
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ class DisplayController extends BaseController
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $default_view = 'tags';
|
protected $default_view = 'dashboard';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ class ImportExportController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function export(): void
|
public function export(): void
|
||||||
{
|
{
|
||||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
|
||||||
|
|
||||||
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
|
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
|
||||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
$app = Factory::getApplication();
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
// Join with #__content to get article titles for reference
|
// Join with #__content to get article titles for reference
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
@@ -60,6 +60,10 @@ class ImportExportController extends BaseController
|
|||||||
$db->quoteName('t.robots'),
|
$db->quoteName('t.robots'),
|
||||||
$db->quoteName('t.canonical_url'),
|
$db->quoteName('t.canonical_url'),
|
||||||
$db->quoteName('t.language'),
|
$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'))
|
->from($db->quoteName('#__mokoog_tags', 't'))
|
||||||
->leftJoin(
|
->leftJoin(
|
||||||
@@ -84,7 +88,7 @@ class ImportExportController extends BaseController
|
|||||||
'content_type', 'content_id', 'article_title',
|
'content_type', 'content_id', 'article_title',
|
||||||
'og_title', 'og_description', 'og_image', 'og_type',
|
'og_title', 'og_description', 'og_image', 'og_type',
|
||||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||||
'language',
|
'language', 'og_video', 'event_data', 'recipe_data', 'custom_schema',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
@@ -102,11 +106,12 @@ class ImportExportController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function import(): void
|
public function import(): void
|
||||||
{
|
{
|
||||||
Session::checkToken() || jexit(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
|
||||||
|
|
||||||
$identity = Factory::getApplication()->getIdentity();
|
$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);
|
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ class ImportExportController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$header = fgetcsv($handle);
|
$header = fgetcsv($handle);
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
@@ -187,6 +192,10 @@ class ImportExportController extends BaseController
|
|||||||
$robots = trim($row[9] ?? '');
|
$robots = trim($row[9] ?? '');
|
||||||
$canonicalUrl = trim($row[10] ?? '');
|
$canonicalUrl = trim($row[10] ?? '');
|
||||||
$language = trim($row[11] ?? '*');
|
$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', '*')
|
// Validate language tag format (e.g., 'en-GB', '*')
|
||||||
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
|
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
|
||||||
@@ -229,6 +238,10 @@ class ImportExportController extends BaseController
|
|||||||
'robots' => $robots,
|
'robots' => $robots,
|
||||||
'canonical_url' => $canonicalUrl,
|
'canonical_url' => $canonicalUrl,
|
||||||
'language' => $language,
|
'language' => $language,
|
||||||
|
'og_video' => $ogVideo,
|
||||||
|
'event_data' => $eventData,
|
||||||
|
'recipe_data' => $recipeData,
|
||||||
|
'custom_schema' => $customSchema,
|
||||||
'published' => 1,
|
'published' => 1,
|
||||||
'modified' => $now,
|
'modified' => $now,
|
||||||
];
|
];
|
||||||
@@ -252,4 +265,45 @@ class ImportExportController extends BaseController
|
|||||||
);
|
);
|
||||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
$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 : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoOG\Administrator\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';
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<html><body bgcolor="#FFFFFF"></body></html>
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoOG\Administrator\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only model providing OG tag coverage metrics for the dashboard.
|
||||||
|
*/
|
||||||
|
class DashboardModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Overall coverage statistics for com_content articles.
|
||||||
|
*
|
||||||
|
* @return array{total:int, with_og:int, coverage:int, missing_title:int, missing_description:int, missing_image:int}
|
||||||
|
*/
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
$total = $this->countContent();
|
||||||
|
$withOg = $this->countDistinct();
|
||||||
|
$missingTitle = $this->countEmptyField('og_title');
|
||||||
|
$missingDesc = $this->countEmptyField('og_description');
|
||||||
|
$missingImage = $this->countEmptyField('og_image');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'with_og' => $withOg,
|
||||||
|
'coverage' => $total > 0 ? (int) round(($withOg / $total) * 100) : 0,
|
||||||
|
'missing_title' => $missingTitle,
|
||||||
|
'missing_description' => $missingDesc,
|
||||||
|
'missing_image' => $missingImage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage broken down by content_type.
|
||||||
|
*
|
||||||
|
* @return array Rows of {content_type, total, with_title, with_image}
|
||||||
|
*/
|
||||||
|
public function getCoverageByType(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$empty = $db->quote('');
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([
|
||||||
|
$db->quoteName('content_type'),
|
||||||
|
'COUNT(*) AS ' . $db->quoteName('total'),
|
||||||
|
'SUM(CASE WHEN ' . $db->quoteName('og_title') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_title'),
|
||||||
|
'SUM(CASE WHEN ' . $db->quoteName('og_image') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_image'),
|
||||||
|
])
|
||||||
|
->from($db->quoteName('#__mokoog_tags'))
|
||||||
|
->where($db->quoteName('published') . ' = 1')
|
||||||
|
->group($db->quoteName('content_type'))
|
||||||
|
->order($db->quoteName('content_type') . ' ASC');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published articles that have no OG tag yet.
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum rows to return
|
||||||
|
*
|
||||||
|
* @return array Rows of {id, title}
|
||||||
|
*/
|
||||||
|
public function getMissingArticles(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([$db->quoteName('c.id'), $db->quoteName('c.title')])
|
||||||
|
->from($db->quoteName('#__content', 'c'))
|
||||||
|
->leftJoin(
|
||||||
|
$db->quoteName('#__mokoog_tags', 't')
|
||||||
|
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
|
||||||
|
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
|
||||||
|
)
|
||||||
|
->where($db->quoteName('c.state') . ' = 1')
|
||||||
|
->where($db->quoteName('t.id') . ' IS NULL')
|
||||||
|
->order($db->quoteName('c.id') . ' DESC');
|
||||||
|
|
||||||
|
$db->setQuery($query, 0, max(1, $limit));
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count published com_content articles.
|
||||||
|
*/
|
||||||
|
private function countContent(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__content'))
|
||||||
|
->where($db->quoteName('state') . ' = 1')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count distinct articles that have at least one published OG tag.
|
||||||
|
*/
|
||||||
|
private function countDistinct(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('COUNT(DISTINCT ' . $db->quoteName('content_id') . ')')
|
||||||
|
->from($db->quoteName('#__mokoog_tags'))
|
||||||
|
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
||||||
|
->where($db->quoteName('published') . ' = 1')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count published OG tag rows whose given field is empty.
|
||||||
|
*
|
||||||
|
* @param string $field One of og_title, og_description, og_image
|
||||||
|
*/
|
||||||
|
private function countEmptyField(string $field): int
|
||||||
|
{
|
||||||
|
// Whitelist the column name — it is never user input here, but keep it strict.
|
||||||
|
if (!\in_array($field, ['og_title', 'og_description', 'og_image'], true)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokoog_tags'))
|
||||||
|
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
||||||
|
->where($db->quoteName('published') . ' = 1')
|
||||||
|
->where($db->quoteName($field) . ' = ' . $db->quote(''))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<html><body bgcolor="#FFFFFF"></body></html>
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoOG\Administrator\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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoOG\Administrator\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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,8 +81,11 @@ class HtmlView extends BaseHtmlView
|
|||||||
protected function addToolbar(): void
|
protected function addToolbar(): void
|
||||||
{
|
{
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark');
|
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('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false);
|
||||||
ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', 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::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
|
||||||
ToolbarHelper::preferences('com_mokoog');
|
ToolbarHelper::preferences('com_mokoog');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
?>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Coverage donut -->
|
||||||
|
<div class="col-lg-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></h4>
|
||||||
|
<svg width="160" height="160" viewBox="0 0 140 140" role="img"
|
||||||
|
aria-label="<?php echo $coverage; ?>%" class="<?php echo $colorClass; ?>">
|
||||||
|
<circle cx="70" cy="70" r="54" fill="none" stroke="#e9ecef" stroke-width="14"></circle>
|
||||||
|
<circle cx="70" cy="70" r="54" fill="none" stroke="<?php echo $stroke; ?>" stroke-width="14"
|
||||||
|
stroke-dasharray="<?php echo $dash; ?> <?php echo $gap; ?>"
|
||||||
|
stroke-linecap="round" transform="rotate(-90 70 70)"></circle>
|
||||||
|
<text x="70" y="80" text-anchor="middle" font-size="30" font-weight="bold" fill="currentColor"><?php echo $coverage; ?>%</text>
|
||||||
|
</svg>
|
||||||
|
<p class="mt-2 mb-0"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $withOg, $total); ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Missing fields -->
|
||||||
|
<div class="col-lg-8 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_FIELD_GAPS'); ?></h4>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
|
<span><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></span>
|
||||||
|
<span class="badge bg-<?php echo ($s['missing_title'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', (int) ($s['missing_title'] ?? 0)); ?></span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
|
<span><?php echo Text::_('COM_MOKOOG_FIELD_OG_DESCRIPTION'); ?></span>
|
||||||
|
<span class="badge bg-<?php echo ($s['missing_description'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', (int) ($s['missing_description'] ?? 0)); ?></span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
|
<span><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></span>
|
||||||
|
<span class="badge bg-<?php echo ($s['missing_image'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', (int) ($s['missing_image'] ?? 0)); ?></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Coverage by content type -->
|
||||||
|
<div class="col-lg-6 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_BY_TYPE'); ?></h4>
|
||||||
|
<?php if (empty($this->byType)) : ?>
|
||||||
|
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('JGRID_HEADING_ID'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($this->byType as $row) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo $this->escape($row->content_type); ?></td>
|
||||||
|
<td class="text-end"><?php echo (int) $row->total; ?></td>
|
||||||
|
<td class="text-end"><?php echo (int) $row->with_title; ?></td>
|
||||||
|
<td class="text-end"><?php echo (int) $row->with_image; ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles missing OG tags -->
|
||||||
|
<div class="col-lg-6 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING'); ?></h4>
|
||||||
|
<a class="btn btn-sm btn-primary" href="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>">
|
||||||
|
<span class="icon-refresh" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOOG_TOOLBAR_BATCH_GENERATE'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($this->missing)) : ?>
|
||||||
|
<p class="text-success mb-0">
|
||||||
|
<span class="icon-check" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOOG_DASHBOARD_ALL_COVERED'); ?>
|
||||||
|
</p>
|
||||||
|
<?php else : ?>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<?php foreach ($this->missing as $article) : ?>
|
||||||
|
<li class="list-group-item py-1">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_content&task=article.edit&id=' . (int) $article->id); ?>">
|
||||||
|
<?php echo $this->escape($article->title); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<small class="text-muted d-block mt-2"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING_NOTE'); ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteOpenGraph
|
||||||
|
* @subpackage com_mokoog
|
||||||
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
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');
|
||||||
|
?>
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tag&layout=edit&id=' . (int) ($this->item->id ?? 0)); ?>"
|
||||||
|
method="post" name="adminForm" id="adminForm" class="form-validate" aria-label="<?php echo $this->escape(Text::_('COM_MOKOOG_TAG_EDIT')); ?>">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<?php echo HTMLHelper::_('uitab.startTabSet', 'mokoogTab', ['active' => 'details']); ?>
|
||||||
|
|
||||||
|
<?php echo HTMLHelper::_('uitab.addTab', 'mokoogTab', 'details', Text::_('COM_MOKOOG_TAB_DETAILS')); ?>
|
||||||
|
<?php echo $this->form->renderFieldset('details'); ?>
|
||||||
|
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||||
|
|
||||||
|
<?php echo HTMLHelper::_('uitab.addTab', 'mokoogTab', 'seo', Text::_('COM_MOKOOG_FIELDSET_SEO')); ?>
|
||||||
|
<?php echo $this->form->renderFieldset('seo'); ?>
|
||||||
|
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||||
|
|
||||||
|
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="task" value="">
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteOpenGraph
|
|
||||||
* @subpackage com_mokoog
|
|
||||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
?>
|
|
||||||
<div class="mokoog-coverage card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 text-center">
|
|
||||||
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
|
|
||||||
<?php echo $coverage; ?>%
|
|
||||||
</div>
|
|
||||||
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-9">
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
|
|
||||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
|
|
||||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
|
|
||||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -21,7 +21,6 @@ use Joomla\CMS\Session\Session;
|
|||||||
|
|
||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
?>
|
?>
|
||||||
<?php include __DIR__ . '/coverage.php'; ?>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
|
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
@@ -85,7 +84,9 @@ $token = Session::getFormToken();
|
|||||||
<?php echo (int) $item->content_id; ?>
|
<?php echo (int) $item->content_id; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
|
<a href="<?php echo Route::_('index.php?option=com_mokoog&task=tag.edit&id=' . (int) $item->id); ?>" title="<?php echo Text::_('JACTION_EDIT'); ?>">
|
||||||
|
<?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item->og_image) : ?>
|
<?php if ($item->og_image) : ?>
|
||||||
@@ -171,6 +172,23 @@ $token = Session::getFormToken();
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Intercept the batch.generate toolbar button
|
// Intercept the batch.generate toolbar button
|
||||||
@@ -180,6 +198,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
mokoogBatchGenerate();
|
mokoogBatchGenerate();
|
||||||
return;
|
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) {
|
if (origSubmitbutton) {
|
||||||
origSubmitbutton(task);
|
origSubmitbutton(task);
|
||||||
}
|
}
|
||||||
@@ -209,27 +234,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
|
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
|
||||||
processChunk(0, total, chunkSize, token, bar, status);
|
processChunk(0, 0, total, chunkSize, token, bar, status);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function processChunk(processed, total, chunkSize, token, bar, status) {
|
function processChunk(lastId, processed, total, chunkSize, token, bar, status) {
|
||||||
// Always offset=0: processed items are excluded by the IS NULL filter
|
// Cursor-based: pass the highest id seen so far. Failed rows fall behind
|
||||||
fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&' + token + '=1')
|
// the cursor and are not re-fetched, so the loop always terminates.
|
||||||
|
fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&lastid=' + lastId + '&' + token + '=1')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(resp) {
|
.then(function(resp) {
|
||||||
processed += resp.data.created;
|
var examined = resp.data.examined || 0;
|
||||||
var pct = Math.min(100, Math.round((processed / total) * 100));
|
processed += examined;
|
||||||
|
var pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 100;
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
bar.textContent = pct + '%';
|
bar.textContent = pct + '%';
|
||||||
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
|
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
|
||||||
|
|
||||||
if (resp.data.created > 0 && processed < total) {
|
if (examined > 0) {
|
||||||
processChunk(processed, total, chunkSize, token, bar, status);
|
processChunk(resp.data.last_id, processed, total, chunkSize, token, bar, status);
|
||||||
} else {
|
} else {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
bar.textContent = '100%';
|
||||||
bar.classList.remove('progress-bar-animated');
|
bar.classList.remove('progress-bar-animated');
|
||||||
bar.classList.add('bg-success');
|
bar.classList.add('bg-success');
|
||||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + processed + ' articles.';
|
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + processed + ' articles.';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="content" method="upgrade">
|
<extension type="plugin" group="content" method="upgrade">
|
||||||
<name>Content - MokoSuiteOpenGraph</name>
|
<name>Content - MokoSuiteOpenGraph</name>
|
||||||
<version>01.05.00</version>
|
<version>01.07.03</version>
|
||||||
<creationDate>2026-05-23</creationDate>
|
<creationDate>2026-05-23</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
|||||||
$contentType = $supportedContexts[$context];
|
$contentType = $supportedContexts[$context];
|
||||||
$contentId = (int) $article->id;
|
$contentId = (int) $article->id;
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->delete($db->quoteName('#__mokoog_tags'))
|
->delete($db->quoteName('#__mokoog_tags'))
|
||||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||||
@@ -208,7 +208,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object
|
private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName([
|
->select($db->quoteName([
|
||||||
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
|
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
|
||||||
@@ -239,7 +239,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void
|
private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
// Check if record exists for this content + language
|
// Check if record exists for this content + language
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
@@ -322,7 +322,14 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
|||||||
{
|
{
|
||||||
$json = trim($json);
|
$json = trim($json);
|
||||||
|
|
||||||
if ($json === '' || json_decode($json) === null) {
|
if ($json === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept JSON objects/arrays. Scalars (42, "x", true) decode to a
|
||||||
|
// non-null value but would crash the frontend renderer when treated as
|
||||||
|
// an array (writing $decoded['@context'] onto a scalar is a fatal error).
|
||||||
|
if (!\is_array(json_decode($json, true))) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="system" method="upgrade">
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
<name>System - MokoSuiteOpenGraph</name>
|
<name>System - MokoSuiteOpenGraph</name>
|
||||||
<version>01.05.00</version>
|
<version>01.07.03</version>
|
||||||
<creationDate>2026-05-23</creationDate>
|
<creationDate>2026-05-23</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
protected $autoloadLanguage = true;
|
protected $autoloadLanguage = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum seconds between full sitemap regenerations (save-time throttle).
|
||||||
|
*/
|
||||||
|
private const SITEMAP_MIN_INTERVAL = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the events this plugin subscribes to.
|
* Returns the events this plugin subscribes to.
|
||||||
*
|
*
|
||||||
@@ -139,7 +144,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// og:locale from current language
|
// og:locale from current language
|
||||||
$langTag = Factory::getLanguage()->getTag();
|
$langTag = $this->getApplication()->getLanguage()->getTag();
|
||||||
$ogLocale = str_replace('-', '_', $langTag);
|
$ogLocale = str_replace('-', '_', $langTag);
|
||||||
$doc->setMetaData('og:locale', $ogLocale, 'property');
|
$doc->setMetaData('og:locale', $ogLocale, 'property');
|
||||||
|
|
||||||
@@ -241,7 +246,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
|
|
||||||
// Pinterest article:tag rich pins (from Joomla content tags)
|
// Pinterest article:tag rich pins (from Joomla content tags)
|
||||||
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$tagQuery = $db->getQuery(true)
|
$tagQuery = $db->getQuery(true)
|
||||||
->select($db->quoteName('t.title'))
|
->select($db->quoteName('t.title'))
|
||||||
->from($db->quoteName('#__tags', 't'))
|
->from($db->quoteName('#__tags', 't'))
|
||||||
@@ -358,7 +363,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
if (!empty($customSchema)) {
|
if (!empty($customSchema)) {
|
||||||
$decoded = json_decode($customSchema, true);
|
$decoded = json_decode($customSchema, true);
|
||||||
|
|
||||||
if ($decoded) {
|
// Guard against scalar/invalid payloads — only arrays/objects are
|
||||||
|
// valid JSON-LD. Writing an array offset onto a scalar is fatal.
|
||||||
|
if (\is_array($decoded) && $decoded !== []) {
|
||||||
if (empty($decoded['@context'])) {
|
if (empty($decoded['@context'])) {
|
||||||
$decoded['@context'] = 'https://schema.org';
|
$decoded['@context'] = 'https://schema.org';
|
||||||
}
|
}
|
||||||
@@ -467,14 +474,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty;
|
return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
->from($db->quoteName('#__mokoog_tags'))
|
->from($db->quoteName('#__mokoog_tags'))
|
||||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($option))
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($option))
|
||||||
->where($db->quoteName('content_id') . ' = ' . (int) $id)
|
->where($db->quoteName('content_id') . ' = ' . (int) $id)
|
||||||
->where($db->quoteName('published') . ' = 1')
|
->where($db->quoteName('published') . ' = 1')
|
||||||
->where('(' . $db->quoteName('language') . ' = ' . $db->quote(Factory::getLanguage()->getTag())
|
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($this->getApplication()->getLanguage()->getTag())
|
||||||
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
||||||
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
||||||
|
|
||||||
@@ -493,8 +500,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function loadOgDataByType(string $contentType, int $contentId): ?object
|
private function loadOgDataByType(string $contentType, int $contentId): ?object
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$lang = Factory::getLanguage()->getTag();
|
$lang = $this->getApplication()->getLanguage()->getTag();
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
@@ -520,8 +527,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function loadOgDataByMenu(int $menuId): ?object
|
private function loadOgDataByMenu(int $menuId): ?object
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$lang = Factory::getLanguage()->getTag();
|
$lang = $this->getApplication()->getLanguage()->getTag();
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('*')
|
->select('*')
|
||||||
@@ -615,7 +622,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
|
|
||||||
// Fallback: check the article's category for an image
|
// Fallback: check the article's category for an image
|
||||||
if ($view === 'article') {
|
if ($view === 'article') {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$catQuery = $db->getQuery(true)
|
$catQuery = $db->getQuery(true)
|
||||||
->select($db->quoteName('cat.params'))
|
->select($db->quoteName('cat.params'))
|
||||||
->from($db->quoteName('#__categories', 'cat'))
|
->from($db->quoteName('#__categories', 'cat'))
|
||||||
@@ -670,11 +677,13 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
{
|
{
|
||||||
static $cache = [];
|
static $cache = [];
|
||||||
|
|
||||||
if (isset($cache[$id])) {
|
// array_key_exists (not isset) so a negative lookup (null) is also cached
|
||||||
|
// and not re-queried on every call within the request.
|
||||||
|
if (\array_key_exists($id, $cache)) {
|
||||||
return $cache[$id];
|
return $cache[$id];
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName([
|
->select($db->quoteName([
|
||||||
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
|
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
|
||||||
@@ -702,8 +711,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
private function getArticleDate(int $id, string $field): string
|
private function getArticleDate(int $id, string $field): string
|
||||||
{
|
{
|
||||||
$article = $this->loadArticle($id);
|
$article = $this->loadArticle($id);
|
||||||
|
$value = $article->$field ?? '';
|
||||||
|
|
||||||
return $article->$field ?? '';
|
// Skip zero/empty dates — emitting "0000-00-00 00:00:00" as
|
||||||
|
// article:published_time/modified_time produces invalid metadata.
|
||||||
|
if ($value === '' || str_starts_with($value, '0000-00-00')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -820,6 +836,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
public function onContentAfterSaveRebuildSitemap(Event $event): void
|
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)) {
|
if (!$this->params->get('sitemap_enabled', 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -830,6 +850,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle: rebuilding the whole sitemap on every save does not scale
|
||||||
|
// (bulk edits/imports). Regenerate at most once per interval — the
|
||||||
|
// sitemap is eventually consistent within that window.
|
||||||
|
$path = JPATH_ROOT . '/sitemap.xml';
|
||||||
|
|
||||||
|
if (is_file($path) && (time() - filemtime($path)) < self::SITEMAP_MIN_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
|
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
|
||||||
$xml = SitemapBuilder::generate($changefreq);
|
$xml = SitemapBuilder::generate($changefreq);
|
||||||
|
|
||||||
@@ -858,6 +887,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require article-edit capability — this triggers outbound paid AI calls,
|
||||||
|
// so it must not be reachable by every authenticated back-end user.
|
||||||
|
if (!$app->getIdentity()->authorise('core.edit', 'com_content')
|
||||||
|
&& !$app->getIdentity()->authorise('core.create', 'com_content')) {
|
||||||
|
$event->setArgument('result', ['Forbidden — insufficient permissions']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->params->get('ai_enabled', 0)) {
|
if (!$this->params->get('ai_enabled', 0)) {
|
||||||
$event->setArgument('result', ['AI generation is not enabled']);
|
$event->setArgument('result', ['AI generation is not enabled']);
|
||||||
return;
|
return;
|
||||||
@@ -902,6 +939,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
{
|
{
|
||||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
|
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
|
||||||
|
|
||||||
|
// Cap how long a hung provider can block the admin request.
|
||||||
|
$timeout = 20;
|
||||||
|
|
||||||
if ($provider === 'claude') {
|
if ($provider === 'claude') {
|
||||||
$response = $http->post(
|
$response = $http->post(
|
||||||
'https://api.anthropic.com/v1/messages',
|
'https://api.anthropic.com/v1/messages',
|
||||||
@@ -914,9 +954,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'x-api-key' => $apiKey,
|
'x-api-key' => $apiKey,
|
||||||
'anthropic-version' => '2023-06-01',
|
'anthropic-version' => '2023-06-01',
|
||||||
]
|
],
|
||||||
|
$timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ((int) $response->code !== 200) {
|
||||||
|
throw new \RuntimeException('Claude API request failed (HTTP ' . (int) $response->code . ')');
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode($response->body, true);
|
$data = json_decode($response->body, true);
|
||||||
|
|
||||||
return trim($data['content'][0]['text'] ?? '');
|
return trim($data['content'][0]['text'] ?? '');
|
||||||
@@ -932,9 +977,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
[
|
[
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Authorization' => 'Bearer ' . $apiKey,
|
'Authorization' => 'Bearer ' . $apiKey,
|
||||||
]
|
],
|
||||||
|
$timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ((int) $response->code !== 200) {
|
||||||
|
throw new \RuntimeException('OpenAI API request failed (HTTP ' . (int) $response->code . ')');
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode($response->body, true);
|
$data = json_decode($response->body, true);
|
||||||
|
|
||||||
return trim($data['choices'][0]['message']['content'] ?? '');
|
return trim($data['choices'][0]['message']['content'] ?? '');
|
||||||
@@ -947,20 +997,20 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function warnMissingLicenseKey(): void
|
private function warnMissingLicenseKey(): void
|
||||||
{
|
{
|
||||||
$session = Factory::getSession();
|
$session = Factory::getApplication()->getSession();
|
||||||
|
|
||||||
if ($session->get('mokoog.license_warned', false)) {
|
if ($session->get('mokoog.license_warned', false)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Factory::getUser();
|
$user = Factory::getApplication()->getIdentity();
|
||||||
|
|
||||||
if ($user->guest || !$user->authorise('core.manage')) {
|
if ($user->guest || !$user->authorise('core.manage')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('extra_query'))
|
->select($db->quoteName('extra_query'))
|
||||||
@@ -1011,7 +1061,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
|
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
|
||||||
->select('c.title AS name, c.introtext AS description, c.images')
|
->select('c.title AS name, c.introtext AS description, c.images')
|
||||||
|
|||||||
@@ -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\CMS\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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,8 @@ namespace Joomla\Plugin\System\MokoOG\Helper;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Filesystem\File;
|
use Joomla\Filesystem\File;
|
||||||
use Joomla\CMS\Filesystem\Folder;
|
use Joomla\Filesystem\Folder;
|
||||||
use Joomla\CMS\Log\Log;
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
class ImageHelper
|
class ImageHelper
|
||||||
@@ -57,96 +57,8 @@ class ImageHelper
|
|||||||
int $targetHeight = self::TARGET_HEIGHT,
|
int $targetHeight = self::TARGET_HEIGHT,
|
||||||
int $quality = self::JPEG_QUALITY
|
int $quality = self::JPEG_QUALITY
|
||||||
): string {
|
): string {
|
||||||
// Resolve absolute path
|
// Thin wrapper over the shared implementation (no subdirectory).
|
||||||
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
return self::resizeToSize($imagePath, $targetWidth, $targetHeight, '', $quality);
|
||||||
|
|
||||||
if (!is_file($absPath)) {
|
|
||||||
return $imagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
$imageInfo = getimagesize($absPath);
|
|
||||||
|
|
||||||
if (!$imageInfo) {
|
|
||||||
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
|
|
||||||
|
|
||||||
return $imagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$origWidth, $origHeight, $type] = $imageInfo;
|
|
||||||
|
|
||||||
// Skip if already at or below target size
|
|
||||||
if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) {
|
|
||||||
return $imagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
|
||||||
|
|
||||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
|
||||||
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
|
|
||||||
|
|
||||||
return $imagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate output filename based on source hash + dimensions
|
|
||||||
$hash = md5($imagePath . $targetWidth . $targetHeight);
|
|
||||||
$outputName = $hash . '.jpg';
|
|
||||||
$outputPath = $outputDir . '/' . $outputName;
|
|
||||||
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
|
|
||||||
|
|
||||||
// Skip if already generated
|
|
||||||
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
|
|
||||||
return $outputRel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load source image
|
|
||||||
$source = self::loadImage($absPath, $type);
|
|
||||||
|
|
||||||
if (!$source) {
|
|
||||||
return $imagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate crop dimensions (center crop to target aspect ratio)
|
|
||||||
$targetRatio = $targetWidth / $targetHeight;
|
|
||||||
$sourceRatio = $origWidth / $origHeight;
|
|
||||||
|
|
||||||
if ($sourceRatio > $targetRatio) {
|
|
||||||
// Source is wider — crop sides
|
|
||||||
$cropHeight = $origHeight;
|
|
||||||
$cropWidth = (int) round($origHeight * $targetRatio);
|
|
||||||
$cropX = (int) round(($origWidth - $cropWidth) / 2);
|
|
||||||
$cropY = 0;
|
|
||||||
} else {
|
|
||||||
// Source is taller — crop top/bottom
|
|
||||||
$cropWidth = $origWidth;
|
|
||||||
$cropHeight = (int) round($origWidth / $targetRatio);
|
|
||||||
$cropX = 0;
|
|
||||||
$cropY = (int) round(($origHeight - $cropHeight) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create output canvas and resample
|
|
||||||
$output = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
||||||
|
|
||||||
imagecopyresampled(
|
|
||||||
$output,
|
|
||||||
$source,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
$cropX,
|
|
||||||
$cropY,
|
|
||||||
$targetWidth,
|
|
||||||
$targetHeight,
|
|
||||||
$cropWidth,
|
|
||||||
$cropHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save as JPEG
|
|
||||||
imagejpeg($output, $outputPath, $quality);
|
|
||||||
|
|
||||||
imagedestroy($source);
|
|
||||||
imagedestroy($output);
|
|
||||||
|
|
||||||
return $outputRel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,11 +94,17 @@ class ImageHelper
|
|||||||
* @param int $width Target width
|
* @param int $width Target width
|
||||||
* @param int $height Target height
|
* @param int $height Target height
|
||||||
* @param string $subdir Subdirectory name for output (e.g. platform name)
|
* @param string $subdir Subdirectory name for output (e.g. platform name)
|
||||||
|
* @param int $quality JPEG quality 1-100
|
||||||
*
|
*
|
||||||
* @return string Path to the output image (relative to JPATH_ROOT)
|
* @return string Path to the output image (relative to JPATH_ROOT)
|
||||||
*/
|
*/
|
||||||
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
|
private static function resizeToSize(
|
||||||
{
|
string $imagePath,
|
||||||
|
int $width,
|
||||||
|
int $height,
|
||||||
|
string $subdir = '',
|
||||||
|
int $quality = self::JPEG_QUALITY
|
||||||
|
): string {
|
||||||
// Resolve absolute path
|
// Resolve absolute path
|
||||||
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
||||||
|
|
||||||
@@ -272,7 +190,7 @@ class ImageHelper
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Save as JPEG
|
// Save as JPEG
|
||||||
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
|
imagejpeg($output, $outputPath, $quality);
|
||||||
|
|
||||||
imagedestroy($source);
|
imagedestroy($source);
|
||||||
imagedestroy($output);
|
imagedestroy($output);
|
||||||
@@ -301,40 +219,36 @@ class ImageHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an image meets minimum OG size requirements.
|
* Prune generated images older than the given age, to bound disk usage.
|
||||||
*
|
*
|
||||||
* @param string $imagePath Image path relative to JPATH_ROOT
|
* The generated-image cache is never otherwise cleaned, so without this it
|
||||||
|
* grows unbounded over time.
|
||||||
*
|
*
|
||||||
* @return array{valid: bool, width: int, height: int, message: string}
|
* @param int $maxAgeDays Delete generated files older than this (default 30)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function validate(string $imagePath): array
|
public static function pruneOldFiles(int $maxAgeDays = 30): void
|
||||||
{
|
{
|
||||||
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
$dir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
||||||
|
|
||||||
if (!is_file($absPath)) {
|
if (!is_dir($dir)) {
|
||||||
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageInfo = getimagesize($absPath);
|
$cutoff = time() - ($maxAgeDays * 86400);
|
||||||
|
|
||||||
if (!$imageInfo) {
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()
|
||||||
|
&& $file->getFilename() !== 'index.html'
|
||||||
|
&& $file->getMTime() < $cutoff) {
|
||||||
|
File::delete($file->getPathname());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[$width, $height] = $imageInfo;
|
|
||||||
|
|
||||||
// Facebook minimum: 200x200, recommended: 1200x630
|
|
||||||
// WhatsApp minimum: 300x200
|
|
||||||
if ($width < 200 || $height < 200) {
|
|
||||||
return [
|
|
||||||
'valid' => false,
|
|
||||||
'width' => $width,
|
|
||||||
'height' => $height,
|
|
||||||
'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class JsonLdBuilder
|
|||||||
$article = $cachedArticle;
|
$article = $cachedArticle;
|
||||||
|
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName([
|
->select($db->quoteName([
|
||||||
'a.created', 'a.modified', 'a.publish_up',
|
'a.created', 'a.modified', 'a.publish_up',
|
||||||
@@ -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.
|
* Build Product schema for a MokoSuiteShop product.
|
||||||
*
|
*
|
||||||
@@ -179,7 +162,7 @@ class JsonLdBuilder
|
|||||||
$product = $cachedProduct;
|
$product = $cachedProduct;
|
||||||
|
|
||||||
if (!$product) {
|
if (!$product) {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('p.sku, p.price, p.currency, p.stock_qty')
|
->select('p.sku, p.price, p.currency, p.stock_qty')
|
||||||
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
|
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ class SitemapBuilder
|
|||||||
$allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
$allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
||||||
$changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly';
|
$changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly';
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
|
// Only include content the public (guest, user id 0) can view — never
|
||||||
|
// leak registered/special-access articles into the public sitemap.
|
||||||
|
$publicLevels = array_map('intval', \Joomla\CMS\Access\Access::getAuthorisedViewLevels(0));
|
||||||
|
|
||||||
// Get all published articles
|
// Get all published articles
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
@@ -43,6 +47,10 @@ class SitemapBuilder
|
|||||||
->from($db->quoteName('#__content', 'a'))
|
->from($db->quoteName('#__content', 'a'))
|
||||||
->where($db->quoteName('a.state') . ' = 1');
|
->where($db->quoteName('a.state') . ' = 1');
|
||||||
|
|
||||||
|
if (!empty($publicLevels)) {
|
||||||
|
$query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')');
|
||||||
|
}
|
||||||
|
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
$articles = $db->loadObjectList();
|
$articles = $db->loadObjectList();
|
||||||
|
|
||||||
@@ -73,7 +81,7 @@ class SitemapBuilder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
|
$url = self::articleUrl($article, $root);
|
||||||
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
|
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
|
||||||
? date('Y-m-d', strtotime($article->modified)) : '';
|
? date('Y-m-d', strtotime($article->modified)) : '';
|
||||||
|
|
||||||
@@ -94,6 +102,45 @@ class SitemapBuilder
|
|||||||
return $xml;
|
return $xml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the SEF/canonical site URL for an article, with a safe fallback.
|
||||||
|
*
|
||||||
|
* Routes through the site router so the sitemap matches the canonical URLs
|
||||||
|
* the plugin emits. If routing fails (or SEF is off), falls back to the
|
||||||
|
* non-SEF index.php URL — never an empty or broken URL.
|
||||||
|
*
|
||||||
|
* @param object $article Row with id, alias, catid, language
|
||||||
|
* @param string $root Site root without trailing slash
|
||||||
|
*
|
||||||
|
* @return string Absolute URL
|
||||||
|
*/
|
||||||
|
private static function articleUrl(object $article, string $root): string
|
||||||
|
{
|
||||||
|
$fallback = $root . '/index.php?option=com_content&view=article&id=' . (int) $article->id;
|
||||||
|
|
||||||
|
$internal = 'index.php?option=com_content&view=article&id=' . (int) $article->id
|
||||||
|
. (!empty($article->alias) ? ':' . $article->alias : '')
|
||||||
|
. (!empty($article->catid) ? '&catid=' . (int) $article->catid : '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$routed = \Joomla\CMS\Router\Route::link(
|
||||||
|
'site',
|
||||||
|
$internal,
|
||||||
|
false,
|
||||||
|
\Joomla\CMS\Router\Route::TLS_IGNORE,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (\is_string($routed) && $routed !== '') {
|
||||||
|
return $routed;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fall back to the non-SEF URL below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write sitemap XML to the site root.
|
* Write sitemap XML to the site root.
|
||||||
*
|
*
|
||||||
@@ -104,7 +151,19 @@ class SitemapBuilder
|
|||||||
public static function writeToFile(string $xml): bool
|
public static function writeToFile(string $xml): bool
|
||||||
{
|
{
|
||||||
$path = JPATH_ROOT . '/sitemap.xml';
|
$path = JPATH_ROOT . '/sitemap.xml';
|
||||||
|
$tmp = $path . '.' . uniqid('tmp', true);
|
||||||
|
|
||||||
return (bool) file_put_contents($path, $xml);
|
if (file_put_contents($tmp, $xml) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace so concurrent saves never expose a half-written sitemap.
|
||||||
|
if (!@rename($tmp, $path)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="webservices" method="upgrade">
|
<extension type="plugin" group="webservices" method="upgrade">
|
||||||
<name>Web Services - MokoSuiteOpenGraph</name>
|
<name>Web Services - MokoSuiteOpenGraph</name>
|
||||||
<version>01.05.00</version>
|
<version>01.07.03</version>
|
||||||
<creationDate>2026-05-23</creationDate>
|
<creationDate>2026-05-23</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteOpenGraph</name>
|
<name>Package - MokoSuiteOpenGraph</name>
|
||||||
<packagename>mokoog</packagename>
|
<packagename>mokoog</packagename>
|
||||||
<version>01.05.00</version>
|
<version>01.07.03</version>
|
||||||
<creationDate>2026-05-23</creationDate>
|
<creationDate>2026-05-23</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</languages>
|
</languages>
|
||||||
|
|
||||||
<updateservers>
|
<updateservers>
|
||||||
<server type="extension" name="MokoSuiteOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/updates.xml</server>
|
<server type="extension" name="MokoSuiteOpenGraph Updates">https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/MokoSuiteOpenGraph/latest/updates.xml</server>
|
||||||
</updateservers>
|
</updateservers>
|
||||||
<dlid prefix="dlid=" suffix=""/>
|
<dlid prefix="dlid=" suffix=""/>
|
||||||
<blockChildUninstall>true</blockChildUninstall>
|
<blockChildUninstall>true</blockChildUninstall>
|
||||||
|
|||||||
+4
-4
@@ -55,7 +55,7 @@ class Pkg_MokoOGInstallerScript
|
|||||||
|
|
||||||
if ($type === 'install')
|
if ($type === 'install')
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
foreach (['system', 'content', 'webservices'] as $folder)
|
foreach (['system', 'content', 'webservices'] as $folder)
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ class Pkg_MokoOGInstallerScript
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = \Joomla\CMS\Factory::getDbo();
|
$db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
$db->getQuery(true)
|
$db->getQuery(true)
|
||||||
->select($db->quoteName('us.extra_query'))
|
->select($db->quoteName('us.extra_query'))
|
||||||
@@ -103,7 +103,7 @@ class Pkg_MokoOGInstallerScript
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = \Joomla\CMS\Factory::getDbo();
|
$db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
$db->getQuery(true)
|
$db->getQuery(true)
|
||||||
->select($db->quoteName('us.update_site_id'))
|
->select($db->quoteName('us.update_site_id'))
|
||||||
@@ -133,7 +133,7 @@ class Pkg_MokoOGInstallerScript
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = \Joomla\CMS\Factory::getDbo();
|
$db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
$db->getQuery(true)
|
$db->getQuery(true)
|
||||||
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
|
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
|
||||||
|
|||||||
Reference in New Issue
Block a user