Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee116b3d94 | |||
| db673be542 | |||
| e392e78234 | |||
| bd5c8d95ec | |||
| 423f2f4cce | |||
| 32bff8e7be | |||
| 08a898cd7e | |||
| 7de7cea10e | |||
| dff90a26cf | |||
| 0d49195f52 | |||
| 9da83f1d40 | |||
| 96261987de | |||
| e702eb8d9e | |||
| febaa856c5 | |||
| acd8da441b | |||
| 437189830f | |||
| 1aa58e1d8d | |||
| 5cb070b3d7 | |||
| 4810371bc0 | |||
| 36c9867857 | |||
| 657e5b2091 | |||
| eda0d222ed | |||
| 1627841983 | |||
| b8ebd8a5fd | |||
| 488f4df65a | |||
| 96f789dcec | |||
| 97619eea0c | |||
| c6ab0cc438 | |||
| a54b621f9d | |||
| 84513a81a5 | |||
| fceb3d5bf5 | |||
| 18e84fbcfe | |||
| c552a12a0e | |||
| 133944620b | |||
| ed5a143439 | |||
| c1fa8c816e | |||
| 1617452a3f | |||
| 313035cfa2 | |||
| a72028a1ad | |||
| 24a40a4117 | |||
| b6ec1cd5b3 | |||
| acef5eb3a3 | |||
| 5743915447 | |||
| 9905d1e634 | |||
| 47594c963d | |||
| 5621542141 | |||
| ee581032c3 | |||
| dd4de77202 | |||
| 3d567353c9 | |||
| 8e7381e3ea | |||
| 3eb56f738e | |||
| dba61e3e0c | |||
| 9c2dd1bdde | |||
| ee49fbbaa6 | |||
| 4785a1f5b4 | |||
| b6202a6a40 | |||
| 0c2074f801 | |||
| e27b958712 | |||
| a169ea4967 | |||
| d951d86b3a | |||
| b03c7c6ba7 | |||
| 1c15497c32 | |||
| 9e38609fe9 | |||
| b907b778c0 | |||
| 4d758890a8 | |||
| 824b4d9ecd | |||
| 307eb7741d | |||
| 4a13ea6ade | |||
| bcc17e4882 | |||
| 4ce96dc95b | |||
| 99e4a83ed5 | |||
| 63c4fbcd14 | |||
| 15a03b309b | |||
| 6f29c077e2 | |||
| ecb1ce592a | |||
| 819d615ede | |||
| f2947a088e | |||
| 845e0bd5fb | |||
| a78b3c224b | |||
| 7136e45a90 | |||
| 882b45cbbd | |||
| 36ab4ff1ac | |||
| f87ae2f922 | |||
| 3a49732dfb | |||
| da9ac28f22 | |||
| 176047d161 | |||
| 2fa2f86bd6 | |||
| ef066edffc | |||
| dec72b6af1 | |||
| 9ca3ab9fc2 | |||
| afc2737663 | |||
| aacf5de7f1 | |||
| 2f10a5fb80 | |||
| 25fb7e9272 | |||
| b39ba30dc5 | |||
| 14c218092b | |||
| 833c8ca41a | |||
| c7551854ac | |||
| 27990652d0 | |||
| 351f1fc7f8 | |||
| 1d5683ceda | |||
| f2cf0dfd24 | |||
| 3a1a201eb2 | |||
| 452d4795ed |
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# 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
|
||||
#
|
||||
# +=======================================================================+
|
||||
@@ -27,7 +27,7 @@ name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
@@ -52,7 +52,7 @@ on:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
@@ -74,6 +75,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -101,7 +103,7 @@ jobs:
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
@@ -120,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
@@ -172,6 +174,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
@@ -268,7 +271,7 @@ jobs:
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
@@ -293,7 +296,7 @@ jobs:
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
@@ -362,7 +365,7 @@ jobs:
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
@@ -391,7 +394,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
@@ -415,7 +418,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
@@ -436,7 +439,7 @@ jobs:
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
@@ -462,5 +465,5 @@ jobs:
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1 +1,68 @@
|
||||
IyBDb3B5cmlnaHQgKEMpIDIwMjYgTW9rbyBDb25zdWx0aW5nIDxoZWxsb0Btb2tvY29uc3VsdGluZy50ZWNoPgojCiMgU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEdQTC0zLjAtb3ItbGF0ZXIKIwojIEZJTEUgSU5GT1JNQVRJT04KIyBERUZHUk9VUDogR2l0ZWEuV29ya2Zsb3cKIyBJTkdST1VQOiBtb2tvY2xpLlVuaXZlcnNhbAojIFJFUE86IGh0dHBzOi8vZ2l0Lm1va29jb25zdWx0aW5nLnRlY2gvTW9rb0NvbnN1bHRpbmcvbW9rb2NsaQojIFBBVEg6IC8ubW9rb2dpdGVhL3dvcmtmbG93cy9jaS1pc3N1ZS1yZXBvcnRlci55bWwKIyBWRVJTSU9OOiAwMS4wMC4wMAojIEJSSUVGOiBSZXVzYWJsZSB3b3JrZmxvdyDigJQgY3JlYXRlcy91cGRhdGVzIGEgR2l0ZWEgaXNzdWUgd2hlbiBhIENJIGdhdGUgZmFpbHMuCiMgICAgICAgIENsb25lcyBNb2tvQ0xJIGFuZCBydW5zIGNsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaC4KCm5hbWU6ICJVbml2ZXJzYWw6IENJIElzc3VlIFJlcG9ydGVyIgoKb246CiAgd29ya2Zsb3dfY2FsbDoKICAgIGlucHV0czoKICAgICAgZ2F0ZToKICAgICAgICBkZXNjcmlwdGlvbjogIkNJIGdhdGUgbmFtZSAoZS5nLiBQUiBWYWxpZGF0aW9uLCBSZXBvc2l0b3J5IEhlYWx0aCkiCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgZGV0YWlsczoKICAgICAgICBkZXNjcmlwdGlvbjogIkh1bWFuLXJlYWRhYmxlIGZhaWx1cmUgZGVzY3JpcHRpb24iCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgc2V2ZXJpdHk6CiAgICAgICAgZGVzY3JpcHRpb246ICJlcnJvciBvciB3YXJuaW5nIgogICAgICAgIHJlcXVpcmVkOiBmYWxzZQogICAgICAgIHR5cGU6IHN0cmluZwogICAgICAgIGRlZmF1bHQ6ICJlcnJvciIKICAgICAgd29ya2Zsb3c6CiAgICAgICAgZGVzY3JpcHRpb246ICJXb3JrZmxvdyBuYW1lIGZvciB0aGUgaXNzdWUgdGl0bGUiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgICAgZGVmYXVsdDogIiIKICAgIHNlY3JldHM6CiAgICAgIE1PS09HSVRFQV9UT0tFTjoKICAgICAgICByZXF1aXJlZDogdHJ1ZQoKZW52OgogIEZPUkNFX0pBVkFTQ1JJUFRfQUNUSU9OU19UT19OT0RFMjQ6IHRydWUKCmpvYnM6CiAgcmVwb3J0OgogICAgbmFtZTogIlJlcG9ydDogJHt7IGlucHV0cy5nYXRlIH19IgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAoKICAgIHN0ZXBzOgogICAgICAtIG5hbWU6IENsb25lIE1va29DTEkKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgTU9LT0dJVEVBX1VSTD0iJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fSIKICAgICAgICAgIGdpdCBjbG9uZSAtLWRlcHRoIDEgLS1maWx0ZXI9YmxvYjpub25lIC0tc3BhcnNlICIke01PS09HSVRFQV9VUkx9L01va29Db25zdWx0aW5nL01va29DTEkuZ2l0IiAvdG1wL21va29jbGkKICAgICAgICAgIGNkIC90bXAvbW9rb2NsaSAmJiBnaXQgc3BhcnNlLWNoZWNrb3V0IHNldCBjbGkvY2lfaXNzdWVfcmVwb3J0ZXIuc2gKCiAgICAgIC0gbmFtZTogUmVwb3J0IENJIGZhaWx1cmUKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgICAgTU9LT0dJVEVBX1VSTDogJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgY2htb2QgK3ggL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaAogICAgICAgICAgL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaCBcCiAgICAgICAgICAgIC0tZ2F0ZSAiJHt7IGlucHV0cy5nYXRlIH19IiBcCiAgICAgICAgICAgIC0tZGV0YWlscyAiJHt7IGlucHV0cy5kZXRhaWxzIH19IiBcCiAgICAgICAgICAgIC0tc2V2ZXJpdHkgIiR7eyBpbnB1dHMuc2V2ZXJpdHkgfX0iIFwKICAgICAgICAgICAgLS13b3JrZmxvdyAiJHt7IGlucHV0cy53b3JrZmxvdyB9fSIK
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||
|
||||
name: "Universal: CI Issue Reporter"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
gate:
|
||||
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||
required: true
|
||||
type: string
|
||||
details:
|
||||
description: "Human-readable failure description"
|
||||
required: true
|
||||
type: string
|
||||
severity:
|
||||
description: "error or warning"
|
||||
required: false
|
||||
type: string
|
||||
default: "error"
|
||||
workflow:
|
||||
description: "Workflow name for the issue title"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
secrets:
|
||||
MOKOGITEA_TOKEN:
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: "Report: ${{ inputs.gate }}"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone MokoCLI
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||
|
||||
- name: Report CI failure
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||
--gate "${{ inputs.gate }}" \
|
||||
--details "${{ inputs.details }}" \
|
||||
--severity "${{ inputs.severity }}" \
|
||||
--workflow "${{ inputs.workflow }}"
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
@@ -33,17 +33,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.08.44
|
||||
# VERSION: 01.13.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
|
||||
+521
-534
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
@@ -55,14 +55,14 @@ jobs:
|
||||
|
||||
- name: Validate metadata against Joomla manifest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||
--path . \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--token "${MOKOGITEA_TOKEN}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--api-base "${GITEA_URL}/api/v1" \
|
||||
--api-base "${MOKOGITEA_URL}/api/v1" \
|
||||
--ci
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# VERSION: 05.02.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -59,6 +59,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Update submodules to main
|
||||
run: |
|
||||
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
|
||||
@@ -29,12 +29,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
env:
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
set -euo pipefail
|
||||
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
|
||||
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
|
||||
fi
|
||||
SUFFIX="${BRANCH#rc/}"
|
||||
DEV_BRANCH="dev/${SUFFIX}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
@@ -42,25 +50,22 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||
"${API}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "201" ]; then
|
||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
@@ -26,8 +27,9 @@ jobs:
|
||||
name: Sync workflows to live repos
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]'))
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
@@ -49,8 +51,14 @@ jobs:
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
|
||||
- name: Install PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
+31
-58
@@ -1,7 +1,27 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.13.00] --- 2026-06-29
|
||||
|
||||
## [01.13.00] --- 2026-06-29
|
||||
|
||||
## [01.12.00] --- 2026-06-28
|
||||
|
||||
## [01.12.00] --- 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Visual post calendar**: Monthly calendar grid view showing scheduled, queued, and posted cross-posts with status badges (#160)
|
||||
- **Calendar navigation**: Month-by-month navigation with today highlighting (#160)
|
||||
- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165)
|
||||
- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range
|
||||
- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day
|
||||
- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload
|
||||
- **Social image generator**: Generate branded 1200x630 OG images with article title overlay using PHP GD (#157)
|
||||
- **Social image config**: Background color, text color, font size, and site name branding options (#157)
|
||||
- **Generate Social Image button**: One-click image generation in the Share Content panel (#157)
|
||||
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
|
||||
- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings
|
||||
- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields
|
||||
- **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries
|
||||
- **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20)
|
||||
- **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts
|
||||
@@ -21,9 +41,19 @@
|
||||
- **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162)
|
||||
- **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162)
|
||||
- **Facebook draft posts**: Save feed posts as unpublished drafts (#162)
|
||||
- **TikTok video upload**: PULL_FROM_URL video publishing via video/init endpoint with status polling (#164)
|
||||
- **TikTok photo carousel**: Up to 35 image carousel posts via content/init endpoint (#164)
|
||||
- **TikTok posting mode**: Configurable DIRECT_POST or MEDIA_UPLOAD (sends to TikTok inbox for in-app editing) (#164)
|
||||
- **TikTok audit warning**: Language string explaining that unverified apps can only create private posts (#164)
|
||||
- **Link shortening**: Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder (#159)
|
||||
- **Site frontend**: Public-facing cross-post list and detail views for site visitors (#133)
|
||||
- **Social preview**: AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in article editor (#156)
|
||||
- **PHPUnit test suite**: Unit tests for models, helpers, and service plugins (#132)
|
||||
|
||||
### Fixed
|
||||
- Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()`
|
||||
- **Content plugin**: Remove Joomla 5 typed event hints -- Joomla 6 dispatches `Model\AfterSaveEvent` instead of `Content\AfterSaveEvent`, causing fatal TypeError on article save
|
||||
- **PreviewController**: Add ACL check and parameterized query to prevent unauthorized article access (IDOR)
|
||||
- Webservices plugin Joomla 6 compatibility -- `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()`
|
||||
|
||||
## [01.07.00] --- 2026-06-23
|
||||
|
||||
@@ -37,60 +67,3 @@
|
||||
### Fixed
|
||||
- **License warning**: Removed duplicate from system plugin (install script already shows it)
|
||||
- **Content plugin**: Fixed func_get_arg crash when non-article content is saved (e.g. update sites, installer)
|
||||
|
||||
## [01.05.00] --- 2026-06-23
|
||||
|
||||
## [01.05.00] --- 2026-06-23
|
||||
|
||||
### Added
|
||||
- **Instagram plugin**: Cross-post to Instagram via Meta Content Publishing API (2-step container flow)
|
||||
- **YouTube plugin**: Cross-post to YouTube via Data API v3 channel bulletins
|
||||
- **Share Content panel**: Per-article editor panel with platform-specific share text fields
|
||||
- **New placeholders**: {social}, {short}, {chat}, {email_subject}, {email_body} for platform-optimized templates
|
||||
- **Share image control**: Choose intro image, fulltext image, custom image, or no image per article
|
||||
- **Mailchimp templates**: Support Mailchimp saved templates with section injection, plus responsive email wrapper fallback
|
||||
- **Delete from platforms**: New MokoSuiteCrossDeleteInterface for removing cross-posted content from remote platforms
|
||||
- **Delete support**: Twitter, Mastodon, Bluesky, Facebook, LinkedIn, Telegram, Discord (7 of 38 plugins)
|
||||
- **Auto-delete on unpublish**: Component config option to delete from platforms when articles are unpublished or trashed
|
||||
- **UTM auto-tagging**: Append utm_source, utm_medium, utm_campaign to shared URLs with {platform} token support
|
||||
- **Caption rotation**: {random:opt1|opt2|opt3} placeholder picks a random option per post
|
||||
- **{url_raw} placeholder**: Clean article URL without UTM parameters
|
||||
- **Mastodon enhancements**: Visibility levels, content warnings, scheduled posts, polls, language tags
|
||||
- **Bluesky threads**: Auto-split long messages into reply chains at sentence boundaries
|
||||
- **Bluesky link cards**: External link card embeds with article title and description
|
||||
- **Ntfy default server**: Default server changed to ntfy.mokoconsulting.tech with configurable plugin params
|
||||
|
||||
### Changed
|
||||
- **Default templates**: Updated to use platform-specific placeholders (social/short/chat/email) with graceful fallback
|
||||
|
||||
### Fixed
|
||||
- **Mailchimp**: Fixed broken namespace placeholder in XML manifest
|
||||
- **ConvertKit**: Removed duplicate curl_setopt_array with undefined $token
|
||||
- **Brevo**: Removed duplicate curl_setopt_array with undefined $token and wrong auth header
|
||||
- **Constant Contact**: Removed duplicate curl_setopt_array
|
||||
- **Mailchimp**: Fixed campaign creation checking HTTP 200 instead of 2xx range
|
||||
- **Medium**: Fixed getUserId() returning array instead of string on error
|
||||
- **Bluesky**: Replaced md5() with hash('sha256', ...) for cache key
|
||||
- **ServiceController**: Exception details no longer exposed to client
|
||||
- **License warning**: Removed duplicate from system plugin -- install script already shows it with direct edit link
|
||||
|
||||
## [01.04.01] --- 2026-06-21
|
||||
|
||||
|
||||
## [01.04.01] --- 2026-06-21
|
||||
|
||||
|
||||
## [01.04.00] --- 2026-06-21
|
||||
|
||||
### Fixed
|
||||
- **Package manifest**: Added missing `plg_system_mokosuitecross_events` and `plg_system_mokosuitecross_gallery` to `pkg_mokosuitecross.xml` — these system plugins were not installed with the package
|
||||
- **Cleanup**: Removed old `src/` directory (pre-rename cruft with `mokojoomcross` files)
|
||||
|
||||
## [01.03.00] --- 2026-06-21
|
||||
|
||||
|
||||
<!-- VERSION: 01.08.44 -->
|
||||
|
||||
All notable changes to MokoSuiteCross will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
|
||||
VERSION: 01.08.44
|
||||
VERSION: 01.13.00
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Community expectations and enforcement guidelines
|
||||
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
||||
|
||||
+119
-1
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
# MokoSuiteCross
|
||||
|
||||
<!-- VERSION: 01.08.44 -->
|
||||
<!-- VERSION: 01.13.00 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 6.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -16,8 +16,14 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
|
||||
- **Post queue** — Scheduled posting, retry on failure, detailed delivery logs
|
||||
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx})
|
||||
- **Share Content panel** — Per-article fields for platform-optimized text (social, short, chat, email) with image picker
|
||||
- **AI caption generation** — Generate platform-optimized captions using Claude or OpenAI with one click
|
||||
- **Social preview** — AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in the article editor
|
||||
- **Social image generator** — Generate Open Graph images with article title overlay using PHP GD
|
||||
- **Link shortening** — Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder
|
||||
- **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares
|
||||
- **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token
|
||||
- **Post calendar** — Visual monthly calendar view of scheduled and completed cross-posts
|
||||
- **Posting analytics** — Best time to post heatmap with per-service breakdown and recommendations
|
||||
- **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms)
|
||||
- **Post history** — Track what was posted where, with platform response data
|
||||
- **Evergreen re-sharing** — Automatically re-share articles on a configurable interval
|
||||
@@ -82,7 +88,7 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
|
||||
| RSS Feed | `plg_mokosuitecross_rssfeed` | Implemented |
|
||||
| ActivityPub | `plg_mokosuitecross_activitypub` | Implemented |
|
||||
| Google Business | `plg_mokosuitecross_googlebusiness` | Implemented |
|
||||
| Nostr | `plg_mokosuitecross_nostr` | Stub (WebSocket deferred) |
|
||||
| Nostr | `plg_mokosuitecross_nostr` | Implemented |
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 01.08.44
|
||||
VERSION: 01.13.00
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
@@ -224,10 +224,10 @@ The following are explicitly out of scope:
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
| Field | Value |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Document | Security Policy |
|
||||
| Path | /SECURITY.md |
|
||||
| Document | Security Policy |
|
||||
| Path | /SECURITY.md |
|
||||
| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) |
|
||||
| Owner | Moko Consulting |
|
||||
| Scope | Security vulnerability handling |
|
||||
|
||||
@@ -227,6 +227,95 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ai" label="COM_MOKOSUITECROSS_CONFIG_AI">
|
||||
<field
|
||||
name="ai_provider"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC"
|
||||
default="none">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE</option>
|
||||
<option value="claude">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE</option>
|
||||
<option value="openai">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI</option>
|
||||
</field>
|
||||
<field
|
||||
name="ai_api_key"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_model"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_MODEL"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC"
|
||||
hint="claude-haiku-4-5 / gpt-4o-mini"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_tone"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_TONE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC"
|
||||
default="professional"
|
||||
showon="ai_provider:claude,openai">
|
||||
<option value="professional">COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL</option>
|
||||
<option value="friendly">COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY</option>
|
||||
<option value="casual">COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="social_image" label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE">
|
||||
<field
|
||||
name="social_image_enabled"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC"
|
||||
default="0"
|
||||
class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="social_image_bg_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
|
||||
default="#1a1a2e"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_text_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
|
||||
default="#ffffff"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_font_size"
|
||||
type="number"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC"
|
||||
default="48"
|
||||
min="24"
|
||||
max="96"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_show_site_name"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="social_image_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
|
||||
<field
|
||||
name="category_rules_note"
|
||||
|
||||
@@ -549,7 +549,43 @@ COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC="Full URL to your YOURL
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN="YOURLS Signature Token"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC="Secret signature token from your YOURLS installation."
|
||||
|
||||
; AI Caption Generation
|
||||
COM_MOKOSUITECROSS_CONFIG_AI="AI Caption Generation"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER="AI Provider"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC="Select an AI provider to generate cross-post captions from article content. The API key is stored in Joomla component params (encrypted at rest)."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE="None (disabled)"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE="Anthropic Claude"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI="OpenAI"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY="API Key"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC="API key for the selected AI provider."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_MODEL="Model"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC="AI model to use. Leave blank for the default (Claude Haiku 4.5 or GPT-4o Mini)."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE="Tone"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC="The writing tone for generated captions."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL="Professional"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY="Friendly"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL="Casual"
|
||||
COM_MOKOSUITECROSS_AI_GENERATE="Generate with AI"
|
||||
COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from the article content using AI."
|
||||
COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..."
|
||||
COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully."
|
||||
COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s"
|
||||
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
|
||||
|
||||
; Category Rules
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release."
|
||||
|
||||
; Post Calendar
|
||||
COM_MOKOSUITECROSS_CALENDAR="Post Calendar"
|
||||
COM_MOKOSUITECROSS_CALENDAR_DESC="Visual calendar of scheduled and posted content"
|
||||
COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Calendar"
|
||||
COM_MOKOSUITECROSS_CALENDAR_TODAY="Today"
|
||||
COM_MOKOSUITECROSS_CALENDAR_MONTH="Month"
|
||||
COM_MOKOSUITECROSS_CALENDAR_WEEK="Week"
|
||||
COM_MOKOSUITECROSS_CALENDAR_LIST="List"
|
||||
COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS="Post rescheduled successfully"
|
||||
COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR="Failed to reschedule post"
|
||||
COM_MOKOSUITECROSS_CALENDAR_CANNOT_RESCHEDULE="Only scheduled or queued posts can be rescheduled"
|
||||
COM_MOKOSUITECROSS_CALENDAR_LOAD_ERROR="Failed to load calendar events"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokosuitecross</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -96,6 +96,27 @@ INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_bo
|
||||
('instagram', 'Instagram Default', '{social}\n\n{hashtags}', 1, 21, NOW()),
|
||||
('youtube', 'YouTube Default', '{social}\n\n{url}', 1, 22, NOW());
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`post_id` int unsigned NOT NULL,
|
||||
`service_id` int unsigned NOT NULL,
|
||||
`service_type` varchar(50) NOT NULL DEFAULT '',
|
||||
`posted_at` datetime DEFAULT NULL,
|
||||
`day_of_week` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`hour_of_day` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`impressions` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagements` int unsigned NOT NULL DEFAULT 0,
|
||||
`clicks` int unsigned NOT NULL DEFAULT 0,
|
||||
`shares` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`created` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_service_type` (`service_type`),
|
||||
KEY `idx_day_hour` (`day_of_week`, `hour_of_day`),
|
||||
KEY `idx_post` (`post_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`category_id` int(10) unsigned NOT NULL,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.45 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.46 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.47 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.49 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.50 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.51 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.52 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.53 — no schema changes */
|
||||
@@ -0,0 +1,23 @@
|
||||
-- MokoSuiteCross 01.08.54 -- Best time to post analytics
|
||||
-- Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`post_id` int unsigned NOT NULL,
|
||||
`service_id` int unsigned NOT NULL,
|
||||
`service_type` varchar(50) NOT NULL DEFAULT '',
|
||||
`posted_at` datetime DEFAULT NULL,
|
||||
`day_of_week` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`hour_of_day` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`impressions` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagements` int unsigned NOT NULL DEFAULT 0,
|
||||
`clicks` int unsigned NOT NULL DEFAULT 0,
|
||||
`shares` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`created` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_service_type` (`service_type`),
|
||||
KEY `idx_day_hour` (`day_of_week`, `hour_of_day`),
|
||||
KEY `idx_post` (`post_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.55 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.56 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.57 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.58 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.59 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.61 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.11.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.11.01 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.11.02 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.11.03 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.00 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.01 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.03 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.04 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.05 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.12.06 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.13.00 — no schema changes */
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AiGeneratorHelper;
|
||||
|
||||
class AiController extends BaseController
|
||||
{
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.edit', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'introtext', 'catid']))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$category = '';
|
||||
$catQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($catQuery);
|
||||
$category = $db->loadResult() ?: '';
|
||||
|
||||
$tagQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('t.title'))
|
||||
->from($db->quoteName('#__tags', 't'))
|
||||
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
|
||||
->where($db->quoteName('m.content_item_id') . ' = ' . $articleId)
|
||||
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'));
|
||||
$db->setQuery($tagQuery);
|
||||
$tags = $db->loadColumn() ?: [];
|
||||
|
||||
$introtext = strip_tags($article->introtext ?? '');
|
||||
$introtext = mb_substr($introtext, 0, 500);
|
||||
|
||||
$params = \Joomla\CMS\Component\ComponentHelper::getParams('com_mokosuitecross');
|
||||
|
||||
$config = [
|
||||
'ai_provider' => $params->get('ai_provider', 'none'),
|
||||
'ai_api_key' => $params->get('ai_api_key', ''),
|
||||
'ai_model' => $params->get('ai_model', ''),
|
||||
'ai_tone' => $params->get('ai_tone', 'professional'),
|
||||
];
|
||||
|
||||
$result = AiGeneratorHelper::generate($article->title, $introtext, $category, $tags, $config);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
|
||||
|
||||
class AnalyticsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Return heatmap grid data as JSON.
|
||||
*
|
||||
* Query params: service_type (string), days (int, default 90)
|
||||
*/
|
||||
public function heatmap(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceType = $this->input->getCmd('service_type', '');
|
||||
$days = $this->input->getInt('days', 90);
|
||||
|
||||
$grid = AnalyticsHelper::getHeatmapData($serviceType, $days);
|
||||
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, 3);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'grid' => $grid,
|
||||
'best_times' => $bestTimes,
|
||||
]);
|
||||
$this->app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the top posting times as JSON.
|
||||
*
|
||||
* Query params: service_type (string), limit (int, default 5)
|
||||
*/
|
||||
public function besttimes(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceType = $this->input->getCmd('service_type', '');
|
||||
$limit = $this->input->getInt('limit', 5);
|
||||
|
||||
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, $limit);
|
||||
$serviceBreakdown = AnalyticsHelper::getServiceBreakdown();
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'best_times' => $bestTimes,
|
||||
'service_breakdown' => $serviceBreakdown,
|
||||
]);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Table\PostTable;
|
||||
|
||||
/**
|
||||
* Calendar controller -- provides AJAX endpoints for the visual post calendar.
|
||||
*
|
||||
* Endpoints:
|
||||
* task=calendar.events -- GET JSON feed for FullCalendar (filtered by start/end)
|
||||
* task=calendar.reschedule -- POST reschedule a post to a new date/time
|
||||
*/
|
||||
class CalendarController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Return posts as FullCalendar-compatible JSON events.
|
||||
*
|
||||
* Query params: start, end (ISO 8601 date range from FullCalendar).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function events(): void
|
||||
{
|
||||
$app = $this->app;
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// ACL check
|
||||
if (!$app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// FullCalendar sends start/end as ISO date strings
|
||||
$start = $this->input->getString('start', '');
|
||||
$end = $this->input->getString('end', '');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'p.' . $db->quoteName('id'),
|
||||
'p.' . $db->quoteName('article_id'),
|
||||
'p.' . $db->quoteName('service_id'),
|
||||
'p.' . $db->quoteName('status'),
|
||||
'p.' . $db->quoteName('scheduled_at'),
|
||||
'p.' . $db->quoteName('posted_at'),
|
||||
'p.' . $db->quoteName('created'),
|
||||
'p.' . $db->quoteName('message'),
|
||||
'a.' . $db->quoteName('title', 'article_title'),
|
||||
's.' . $db->quoteName('title', 'service_title'),
|
||||
's.' . $db->quoteName('service_type'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->leftJoin(
|
||||
$db->quoteName('#__content', 'a')
|
||||
. ' ON ' . $db->quoteName('a.id') . ' = ' . $db->quoteName('p.article_id')
|
||||
)
|
||||
->leftJoin(
|
||||
$db->quoteName('#__mokosuitecross_services', 's')
|
||||
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')
|
||||
)
|
||||
->order($db->quoteName('p.created') . ' DESC');
|
||||
|
||||
// Filter by date range when provided
|
||||
if ($start !== '') {
|
||||
$dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)';
|
||||
$query->where($dateExpr . ' >= ' . $db->quote($start));
|
||||
}
|
||||
|
||||
if ($end !== '') {
|
||||
$dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)';
|
||||
$query->where($dateExpr . ' <= ' . $db->quote($end));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
// Map status to colour
|
||||
$statusColors = [
|
||||
'posted' => '#28a745',
|
||||
'scheduled' => '#007bff',
|
||||
'queued' => '#ffc107',
|
||||
'failed' => '#dc3545',
|
||||
'posting' => '#17a2b8',
|
||||
];
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// Pick the best date for the calendar event
|
||||
$eventDate = $row->scheduled_at ?: ($row->posted_at ?: $row->created);
|
||||
|
||||
// Skip rows with no usable date
|
||||
if (empty($eventDate) || $eventDate === '0000-00-00 00:00:00') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = ($row->article_title ?: 'Post #' . $row->id);
|
||||
|
||||
if ($row->service_title) {
|
||||
$title .= ' - ' . $row->service_title;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'id' => (int) $row->id,
|
||||
'title' => $title,
|
||||
'start' => $eventDate,
|
||||
'color' => $statusColors[$row->status] ?? '#6c757d',
|
||||
'url' => 'index.php?option=com_mokosuitecross&task=post.edit&id=' . (int) $row->id,
|
||||
'extendedProps' => [
|
||||
'status' => $row->status,
|
||||
'service_type' => $row->service_type ?? '',
|
||||
'article_id' => (int) $row->article_id,
|
||||
'service_id' => (int) $row->service_id,
|
||||
'message' => mb_substr($row->message ?? '', 0, 200),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse($events, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule a post to a new date/time via drag-drop.
|
||||
*
|
||||
* POST params: post_id (int), new_date (ISO 8601 datetime string).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function reschedule(): void
|
||||
{
|
||||
$app = $this->app;
|
||||
|
||||
// CSRF check
|
||||
if (!Session::checkToken('post')) {
|
||||
$this->sendJsonResponse(['error' => Text::_('JINVALID_TOKEN')], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ACL check
|
||||
if (!$app->getIdentity()->authorise('core.edit', 'com_mokosuitecross')) {
|
||||
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$postId = $this->input->getInt('post_id', 0);
|
||||
$newDate = $this->input->getString('new_date', '');
|
||||
|
||||
if ($postId < 1 || $newDate === '') {
|
||||
$this->sendJsonResponse(
|
||||
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
|
||||
400
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the date format
|
||||
try {
|
||||
$dateObj = Factory::getDate($newDate);
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJsonResponse(
|
||||
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
|
||||
400
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the post using Table bind/check/store pattern
|
||||
$db = Factory::getDbo();
|
||||
$table = new PostTable($db);
|
||||
|
||||
if (!$table->load($postId)) {
|
||||
$this->sendJsonResponse(
|
||||
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
|
||||
404
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow rescheduling of scheduled or queued posts
|
||||
$allowedStatuses = ['scheduled', 'queued'];
|
||||
|
||||
if (!in_array($table->status, $allowedStatuses, true)) {
|
||||
$this->sendJsonResponse(
|
||||
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
|
||||
400
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the post
|
||||
$data = [
|
||||
'scheduled_at' => $dateObj->toSql(),
|
||||
'status' => 'scheduled',
|
||||
'modified' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
if (!$table->bind($data) || !$table->check() || !$table->store()) {
|
||||
$this->sendJsonResponse(
|
||||
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
|
||||
500
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the reschedule
|
||||
$log = (object) [
|
||||
'post_id' => $postId,
|
||||
'service_id' => (int) $table->service_id,
|
||||
'level' => 'info',
|
||||
'message' => sprintf('Post rescheduled to %s via calendar drag-drop', $dateObj->toSql()),
|
||||
'context' => '{}',
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitecross_logs', $log);
|
||||
|
||||
$this->sendJsonResponse(
|
||||
[
|
||||
'success' => true,
|
||||
'message' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS'),
|
||||
],
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*
|
||||
* @param array $data Response data
|
||||
* @param int $httpCode HTTP status code
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function sendJsonResponse(array $data, int $httpCode): void
|
||||
{
|
||||
$app = $this->app;
|
||||
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
$app->setHeader('Status', (string) $httpCode);
|
||||
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,17 @@ class PreviewController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuitecross')
|
||||
&& !$user->authorise('core.edit', 'com_content')
|
||||
&& !$user->authorise('core.edit.own', 'com_content')) {
|
||||
echo json_encode(['error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
$platform = $this->input->getCmd('platform', 'twitter');
|
||||
|
||||
@@ -43,10 +54,14 @@ class PreviewController extends BaseController
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$groups = $user->getAuthorisedViewLevels();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
->where($db->quoteName('id') . ' = :id')
|
||||
->where($db->quoteName('access') . ' IN (' . implode(',', array_map('intval', $groups)) . ')')
|
||||
->bind(':id', $articleId, \Joomla\Database\ParameterType::INTEGER);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\SocialImageHelper;
|
||||
|
||||
class SocialImageController extends BaseController
|
||||
{
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.edit', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
|
||||
if (!(int) $params->get('social_image_enabled', 0)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Social image generator is not enabled']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$title = $db->loadResult();
|
||||
|
||||
if (!$title) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$siteName = $this->app->get('sitename', '');
|
||||
|
||||
$config = [
|
||||
'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
|
||||
'text_color' => $params->get('social_image_text_color', '#ffffff'),
|
||||
'font_size' => $params->get('social_image_font_size', 48),
|
||||
'show_site_name' => (bool) $params->get('social_image_show_site_name', 1),
|
||||
];
|
||||
|
||||
$result = SocialImageHelper::generate($title, $siteName, $config);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class AiGeneratorHelper
|
||||
{
|
||||
public static function generate(string $title, string $introtext, string $category, array $tags, array $config): array
|
||||
{
|
||||
$provider = $config['ai_provider'] ?? 'none';
|
||||
$apiKey = $config['ai_api_key'] ?? '';
|
||||
$model = $config['ai_model'] ?? '';
|
||||
$tone = $config['ai_tone'] ?? 'professional';
|
||||
|
||||
if ($provider === 'none' || $apiKey === '') {
|
||||
return ['success' => false, 'error' => 'AI provider not configured or API key missing.'];
|
||||
}
|
||||
|
||||
$prompt = self::buildPrompt($title, $introtext, $category, $tags, $tone);
|
||||
|
||||
$response = match ($provider) {
|
||||
'claude' => self::callClaude($prompt, $apiKey, $model ?: 'claude-haiku-4-5'),
|
||||
'openai' => self::callOpenAI($prompt, $apiKey, $model ?: 'gpt-4o-mini'),
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($response === '') {
|
||||
return ['success' => false, 'error' => 'AI provider returned an empty response.'];
|
||||
}
|
||||
|
||||
$parsed = self::parseResponse($response);
|
||||
|
||||
if ($parsed === null) {
|
||||
return ['success' => false, 'error' => 'Could not parse AI response as JSON.'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $parsed];
|
||||
}
|
||||
|
||||
private static function callClaude(string $prompt, string $apiKey, string $model): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 500,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
curl_close($ch);
|
||||
return '';
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['content'][0]['text'] ?? '';
|
||||
}
|
||||
|
||||
private static function callOpenAI(string $prompt, string $apiKey, string $model): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 500,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
curl_close($ch);
|
||||
return '';
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['choices'][0]['message']['content'] ?? '';
|
||||
}
|
||||
|
||||
private static function buildPrompt(string $title, string $introtext, string $category, array $tags, string $tone): string
|
||||
{
|
||||
$tagList = !empty($tags) ? implode(', ', $tags) : 'none';
|
||||
|
||||
$toneGuide = match ($tone) {
|
||||
'casual' => 'Use a relaxed, conversational tone.',
|
||||
'friendly' => 'Use a warm, approachable tone with enthusiasm.',
|
||||
default => 'Use a professional, polished tone.',
|
||||
};
|
||||
|
||||
return <<<PROMPT
|
||||
Generate cross-post captions for this article. {$toneGuide}
|
||||
|
||||
Article title: {$title}
|
||||
Content summary: {$introtext}
|
||||
Category: {$category}
|
||||
Tags: {$tagList}
|
||||
|
||||
Return ONLY a JSON object with these keys (no markdown, no explanation):
|
||||
{
|
||||
"social": "Facebook/LinkedIn post (max 200 chars, include a call to action)",
|
||||
"short": "Twitter/Bluesky post (max 270 chars, punchy, include 1-2 relevant hashtags)",
|
||||
"chat": "Telegram/Discord message (max 300 chars, conversational)",
|
||||
"email_subject": "Email subject line (max 60 chars, compelling, no clickbait)"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Do not include the article URL (it is added automatically)
|
||||
- Do not wrap the JSON in markdown code fences
|
||||
- Respect the character limits strictly
|
||||
- Each caption should be unique, not just a reformatted version of the others
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private static function parseResponse(string $response): ?array
|
||||
{
|
||||
$response = trim($response);
|
||||
|
||||
if (preg_match('/\{[\s\S]*\}/', $response, $matches)) {
|
||||
$response = $matches[0];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
if (!\is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$required = ['social', 'short', 'chat', 'email_subject'];
|
||||
|
||||
foreach ($required as $key) {
|
||||
if (!isset($data[$key]) || !\is_string($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'social' => mb_substr($data['social'], 0, 500),
|
||||
'short' => mb_substr($data['short'], 0, 280),
|
||||
'chat' => mb_substr($data['chat'], 0, 500),
|
||||
'email_subject' => mb_substr($data['email_subject'], 0, 120),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class AnalyticsHelper
|
||||
{
|
||||
/**
|
||||
* Record or update engagement metrics for a post.
|
||||
*
|
||||
* @param int $postId The post ID
|
||||
* @param int $serviceId The service ID
|
||||
* @param string $serviceType The service type (e.g. twitter, facebook)
|
||||
* @param array $metrics Engagement metrics: impressions, engagements, clicks, shares, posted_at
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$postedAt = $metrics['posted_at'] ?? null;
|
||||
|
||||
if ($postedAt) {
|
||||
$timestamp = strtotime($postedAt);
|
||||
$dayOfWeek = (int) date('w', $timestamp);
|
||||
$hourOfDay = (int) date('G', $timestamp);
|
||||
} else {
|
||||
$dayOfWeek = 0;
|
||||
$hourOfDay = 0;
|
||||
}
|
||||
|
||||
$impressions = (int) ($metrics['impressions'] ?? 0);
|
||||
$engagements = (int) ($metrics['engagements'] ?? 0);
|
||||
$clicks = (int) ($metrics['clicks'] ?? 0);
|
||||
$shares = (int) ($metrics['shares'] ?? 0);
|
||||
|
||||
$engagementRate = $impressions > 0
|
||||
? round(($engagements / $impressions) * 100, 2)
|
||||
: 0.00;
|
||||
|
||||
// Check if a row already exists for this post
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->where($db->quoteName('post_id') . ' = ' . $postId)
|
||||
->where($db->quoteName('service_id') . ' = ' . $serviceId);
|
||||
$db->setQuery($query);
|
||||
$existingId = $db->loadResult();
|
||||
|
||||
if ($existingId) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->set($db->quoteName('impressions') . ' = ' . $impressions)
|
||||
->set($db->quoteName('engagements') . ' = ' . $engagements)
|
||||
->set($db->quoteName('clicks') . ' = ' . $clicks)
|
||||
->set($db->quoteName('shares') . ' = ' . $shares)
|
||||
->set($db->quoteName('engagement_rate') . ' = ' . $engagementRate)
|
||||
->where($db->quoteName('id') . ' = ' . (int) $existingId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$record = (object) [
|
||||
'post_id' => $postId,
|
||||
'service_id' => $serviceId,
|
||||
'service_type' => $serviceType,
|
||||
'posted_at' => $postedAt,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
'hour_of_day' => $hourOfDay,
|
||||
'impressions' => $impressions,
|
||||
'engagements' => $engagements,
|
||||
'clicks' => $clicks,
|
||||
'shares' => $shares,
|
||||
'engagement_rate' => $engagementRate,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitecross_analytics', $record);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data as a 7x24 grid of average engagement rates.
|
||||
*
|
||||
* @param string $serviceType Optional service type filter
|
||||
* @param int $days Number of days to look back (0 = all time)
|
||||
*
|
||||
* @return array 7x24 grid: [ day_of_week => [ hour_of_day => avg_engagement_rate ] ]
|
||||
*/
|
||||
public static function getHeatmapData(string $serviceType = '', int $days = 90): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('day_of_week'),
|
||||
$db->quoteName('hour_of_day'),
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate',
|
||||
'COUNT(*) AS post_count',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('day_of_week'))
|
||||
->group($db->quoteName('hour_of_day'))
|
||||
->order($db->quoteName('day_of_week') . ' ASC')
|
||||
->order($db->quoteName('hour_of_day') . ' ASC');
|
||||
|
||||
if ($serviceType !== '') {
|
||||
$query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType));
|
||||
}
|
||||
|
||||
if ($days > 0) {
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList();
|
||||
|
||||
// Build 7x24 grid initialised to zero
|
||||
$grid = [];
|
||||
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$grid[$d][$h] = ['avg_rate' => 0.00, 'post_count' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [
|
||||
'avg_rate' => round((float) $row->avg_rate, 2),
|
||||
'post_count' => (int) $row->post_count,
|
||||
];
|
||||
}
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best times to post ranked by average engagement rate.
|
||||
*
|
||||
* @param string $serviceType Optional service type filter
|
||||
* @param int $limit Number of results to return
|
||||
*
|
||||
* @return array List of [day_of_week, hour_of_day, avg_rate, post_count]
|
||||
*/
|
||||
public static function getBestTimes(string $serviceType = '', int $limit = 5): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('day_of_week'),
|
||||
$db->quoteName('hour_of_day'),
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate',
|
||||
'COUNT(*) AS post_count',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('day_of_week'))
|
||||
->group($db->quoteName('hour_of_day'))
|
||||
->having('COUNT(*) >= 1')
|
||||
->order('avg_rate DESC');
|
||||
|
||||
if ($serviceType !== '') {
|
||||
$query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType));
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
$dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$hour = (int) $row['hour_of_day'];
|
||||
$ampm = $hour < 12 ? 'AM' : 'PM';
|
||||
$hour12 = $hour % 12 ?: 12;
|
||||
|
||||
$results[] = [
|
||||
'day_of_week' => (int) $row['day_of_week'],
|
||||
'day_name' => $dayNames[(int) $row['day_of_week']],
|
||||
'hour_of_day' => $hour,
|
||||
'hour_label' => $hour12 . ':00 ' . $ampm,
|
||||
'avg_rate' => round((float) $row['avg_rate'], 2),
|
||||
'post_count' => (int) $row['post_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement stats grouped by service type.
|
||||
*
|
||||
* @param int $days Number of days to look back (0 = all time)
|
||||
*
|
||||
* @return array List of [service_type, total_posts, avg_engagement_rate, total_impressions, total_engagements]
|
||||
*/
|
||||
public static function getServiceBreakdown(int $days = 30): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('service_type'),
|
||||
'COUNT(*) AS total_posts',
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_engagement_rate',
|
||||
'SUM(' . $db->quoteName('impressions') . ') AS total_impressions',
|
||||
'SUM(' . $db->quoteName('engagements') . ') AS total_engagements',
|
||||
'SUM(' . $db->quoteName('clicks') . ') AS total_clicks',
|
||||
'SUM(' . $db->quoteName('shares') . ') AS total_shares',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('service_type'))
|
||||
->order('avg_engagement_rate DESC');
|
||||
|
||||
if ($days > 0) {
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['avg_engagement_rate'] = round((float) $row['avg_engagement_rate'], 2);
|
||||
$row['total_posts'] = (int) $row['total_posts'];
|
||||
$row['total_impressions'] = (int) $row['total_impressions'];
|
||||
$row['total_engagements'] = (int) $row['total_engagements'];
|
||||
$row['total_clicks'] = (int) $row['total_clicks'];
|
||||
$row['total_shares'] = (int) $row['total_shares'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,10 @@ class MokoSuiteCrossHelper
|
||||
'posts' => 'COM_MOKOSUITECROSS_SUBMENU_POSTS',
|
||||
'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES',
|
||||
'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES',
|
||||
'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR',
|
||||
'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS',
|
||||
'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR',
|
||||
'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS',
|
||||
];
|
||||
|
||||
// Joomla 5+ toolbar submenu
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class SocialImageHelper
|
||||
{
|
||||
private const WIDTH = 1200;
|
||||
private const HEIGHT = 630;
|
||||
|
||||
/**
|
||||
* Generate a branded social/OG image with text overlay.
|
||||
*
|
||||
* @param string $title Article title to render on the image
|
||||
* @param string $siteName Site name for branding watermark
|
||||
* @param array $config Rendering config: bg_color, text_color, font_size, show_site_name
|
||||
*
|
||||
* @return array ['success' => bool, 'image_url' => string, 'error' => string]
|
||||
*/
|
||||
public static function generate(string $title, string $siteName, array $config): array
|
||||
{
|
||||
if (!\function_exists('imagecreatetruecolor')) {
|
||||
return ['success' => false, 'error' => 'PHP GD extension is not available'];
|
||||
}
|
||||
|
||||
$bgColor = $config['bg_color'] ?? '#1a1a2e';
|
||||
$textColor = $config['text_color'] ?? '#ffffff';
|
||||
$fontSize = (int) ($config['font_size'] ?? 48);
|
||||
$showSiteName = (bool) ($config['show_site_name'] ?? true);
|
||||
|
||||
$fontSize = max(24, min(96, $fontSize));
|
||||
|
||||
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
|
||||
if ($image === false) {
|
||||
return ['success' => false, 'error' => 'Failed to create image canvas'];
|
||||
}
|
||||
|
||||
$bgRgb = self::hexToRgb($bgColor);
|
||||
$textRgb = self::hexToRgb($textColor);
|
||||
|
||||
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
|
||||
$text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
|
||||
|
||||
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
|
||||
|
||||
$fontFile = self::findFont();
|
||||
|
||||
if ($fontFile !== null) {
|
||||
self::renderTtfText($image, $title, $text, $fontSize, $fontFile);
|
||||
|
||||
if ($showSiteName && $siteName !== '') {
|
||||
$siteSize = (int) round($fontSize * 0.45);
|
||||
$siteBox = imagettfbbox($siteSize, 0, $fontFile, $siteName);
|
||||
$siteX = self::WIDTH - ($siteBox[2] - $siteBox[0]) - 40;
|
||||
$siteY = self::HEIGHT - 30;
|
||||
imagettftext($image, $siteSize, 0, $siteX, $siteY, $text, $fontFile, $siteName);
|
||||
}
|
||||
} else {
|
||||
self::renderFallbackText($image, $title, $text);
|
||||
|
||||
if ($showSiteName && $siteName !== '') {
|
||||
$siteX = self::WIDTH - (\strlen($siteName) * imagefontwidth(3)) - 40;
|
||||
$siteY = self::HEIGHT - 30;
|
||||
imagestring($image, 3, $siteX, $siteY, $siteName, $text);
|
||||
}
|
||||
}
|
||||
|
||||
$outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
$hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
|
||||
$filename = $hash . '.png';
|
||||
$filePath = $outputDir . '/' . $filename;
|
||||
|
||||
if (!imagepng($image, $filePath, 6)) {
|
||||
imagedestroy($image);
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to save image file'];
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
$imageUrl = 'media/com_mokosuitecross/social/' . $filename;
|
||||
|
||||
return ['success' => true, 'image_url' => $imageUrl];
|
||||
}
|
||||
|
||||
private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
|
||||
{
|
||||
$maxWidth = self::WIDTH - 120;
|
||||
$lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
|
||||
$lineHeight = (int) round($fontSize * 1.4);
|
||||
$totalHeight = \count($lines) * $lineHeight;
|
||||
|
||||
$startY = (int) round((self::HEIGHT - $totalHeight) / 2) + $fontSize;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$y = $startY + ($i * $lineHeight);
|
||||
imagettftext($image, $fontSize, 0, 60, $y, $color, $fontFile, $line);
|
||||
}
|
||||
}
|
||||
|
||||
private static function renderFallbackText(\GdImage $image, string $title, int $color): void
|
||||
{
|
||||
$font = 5;
|
||||
$charWidth = imagefontwidth($font);
|
||||
$charHeight = imagefontheight($font);
|
||||
$maxChars = (int) floor((self::WIDTH - 120) / $charWidth);
|
||||
$lines = wordwrap($title, $maxChars, "\n", true);
|
||||
$lineArray = explode("\n", $lines);
|
||||
$lineHeight = $charHeight + 8;
|
||||
$totalHeight = \count($lineArray) * $lineHeight;
|
||||
$startY = (int) round((self::HEIGHT - $totalHeight) / 2);
|
||||
|
||||
foreach ($lineArray as $i => $line) {
|
||||
$y = $startY + ($i * $lineHeight);
|
||||
imagestring($image, $font, 60, $y, $line, $color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-wrap text for TTF rendering at a given pixel width.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
|
||||
{
|
||||
$words = explode(' ', $text);
|
||||
$lines = [];
|
||||
$currentLine = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
|
||||
$box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
|
||||
$width = abs($box[2] - $box[0]);
|
||||
|
||||
if ($width > $maxWidth && $currentLine !== '') {
|
||||
$lines[] = $currentLine;
|
||||
$currentLine = $word;
|
||||
} else {
|
||||
$currentLine = $testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentLine !== '') {
|
||||
$lines[] = $currentLine;
|
||||
}
|
||||
|
||||
return $lines ?: [$text];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a usable TTF font file -- check common system locations.
|
||||
*/
|
||||
private static function findFont(): ?string
|
||||
{
|
||||
$candidates = [
|
||||
JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf',
|
||||
JPATH_ROOT . '/media/com_mokosuitecross/fonts/Roboto-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
|
||||
'C:/Windows/Fonts/arial.ttf',
|
||||
'C:/Windows/Fonts/segoeui.ttf',
|
||||
];
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[] [r, g, b]
|
||||
*/
|
||||
private static function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
|
||||
if (\strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
return [
|
||||
(int) hexdec(substr($hex, 0, 2)),
|
||||
(int) hexdec(substr($hex, 2, 2)),
|
||||
(int) hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class AnalyticsModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getHeatmap(int $days = 90, ?int $serviceId = null): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
|
||||
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts'))
|
||||
->where($db->quoteName('posted_at') . ' IS NOT NULL')
|
||||
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
|
||||
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')')
|
||||
->order('dow ASC, hour_of_day ASC');
|
||||
|
||||
if ($serviceId !== null && $serviceId > 0) {
|
||||
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadAssocList() ?: [];
|
||||
|
||||
$grid = [];
|
||||
|
||||
for ($d = 1; $d <= 7; $d++) {
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$grid[$d][$h] = ['total' => 0, 'success' => 0, 'rate' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$d = (int) $row['dow'];
|
||||
$h = (int) $row['hour_of_day'];
|
||||
$grid[$d][$h] = [
|
||||
'total' => (int) $row['total'],
|
||||
'success' => (int) $row['success'],
|
||||
'rate' => (int) $row['total'] > 0
|
||||
? round(((int) $row['success'] / (int) $row['total']) * 100)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
public function getBestTimes(int $days = 90, ?int $serviceId = null, int $limit = 5): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
|
||||
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts'))
|
||||
->where($db->quoteName('posted_at') . ' IS NOT NULL')
|
||||
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
|
||||
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')')
|
||||
->having('COUNT(*) >= 3')
|
||||
->order('success DESC, total DESC');
|
||||
|
||||
if ($serviceId !== null && $serviceId > 0) {
|
||||
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
public function getHourlyDistribution(int $days = 90, ?int $serviceId = null): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts'))
|
||||
->where($db->quoteName('posted_at') . ' IS NOT NULL')
|
||||
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
|
||||
->group('HOUR(' . $db->quoteName('posted_at') . ')')
|
||||
->order('hour_of_day ASC');
|
||||
|
||||
if ($serviceId !== null && $serviceId > 0) {
|
||||
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
public function getDayOfWeekDistribution(int $days = 90, ?int $serviceId = null): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
|
||||
'COUNT(*) AS total',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
|
||||
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts'))
|
||||
->where($db->quoteName('posted_at') . ' IS NOT NULL')
|
||||
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
|
||||
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . ')')
|
||||
->order('dow ASC');
|
||||
|
||||
if ($serviceId !== null && $serviceId > 0) {
|
||||
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
public function getServices(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('title'), $db->quoteName('service_type')])
|
||||
->from($db->quoteName('#__mokosuitecross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('title') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class CalendarModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get cross-post events for a given month, grouped by date.
|
||||
*
|
||||
* @param int $year Four-digit year
|
||||
* @param int $month Month number (1-12)
|
||||
*
|
||||
* @return array Associative array keyed by Y-m-d, each value an array of event objects
|
||||
*/
|
||||
public function getEvents(int $year, int $month): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$firstDay = sprintf('%04d-%02d-01', $year, $month);
|
||||
$lastDay = date('Y-m-t', strtotime($firstDay));
|
||||
|
||||
$dateExpr = 'COALESCE('
|
||||
. $db->quoteName('p.scheduled_at') . ', '
|
||||
. $db->quoteName('p.posted_at') . ', '
|
||||
. $db->quoteName('p.created') . ')';
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'DATE(' . $dateExpr . ') AS event_date',
|
||||
$db->quoteName('p.status'),
|
||||
$db->quoteName('s.service_type'),
|
||||
$db->quoteName('c.title', 'article_title'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitecross_services', 's')
|
||||
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
|
||||
->join('LEFT', $db->quoteName('#__content', 'c')
|
||||
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id'))
|
||||
->where('DATE(' . $dateExpr . ') >= ' . $db->quote($firstDay))
|
||||
->where('DATE(' . $dateExpr . ') <= ' . $db->quote($lastDay))
|
||||
->order('DATE(' . $dateExpr . ') ASC, ' . $db->quoteName('p.created') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$grouped = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$grouped[$row->event_date][] = $row;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Analytics;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public $heatmap;
|
||||
public $bestTimes;
|
||||
public $hourlyDistribution;
|
||||
public $dayDistribution;
|
||||
public $services;
|
||||
public $serviceId;
|
||||
public $period;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\Model\AnalyticsModel $model */
|
||||
$model = $this->getModel();
|
||||
|
||||
$input = Factory::getApplication()->input;
|
||||
$this->period = $input->getInt('period', 90);
|
||||
$this->serviceId = $input->getInt('service_id', 0);
|
||||
|
||||
$validPeriods = [7, 30, 90, 180, 365];
|
||||
|
||||
if (!\in_array($this->period, $validPeriods, true)) {
|
||||
$this->period = 90;
|
||||
}
|
||||
|
||||
$sid = $this->serviceId > 0 ? $this->serviceId : null;
|
||||
|
||||
$this->heatmap = $model->getHeatmap($this->period, $sid);
|
||||
$this->bestTimes = $model->getBestTimes($this->period, $sid);
|
||||
$this->hourlyDistribution = $model->getHourlyDistribution($this->period, $sid);
|
||||
$this->dayDistribution = $model->getDayOfWeekDistribution($this->period, $sid);
|
||||
$this->services = $model->getServices();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
MokoSuiteCrossHelper::addSubmenu('analytics');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('MokoSuiteCross -- Analytics', 'chart');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Calendar;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public $sidebar;
|
||||
public $ajaxUrl;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
// ACL check
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
if (!$canDo->get('core.manage')) {
|
||||
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
// Build AJAX URL for FullCalendar event source
|
||||
$this->ajaxUrl = Route::_('index.php?option=com_mokosuitecross&task=calendar.events&format=json', false);
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
MokoSuiteCrossHelper::addSubmenu('calendar');
|
||||
$this->sidebar = \Joomla\CMS\HTML\Sidebar::render();
|
||||
|
||||
// Set document title
|
||||
Factory::getApplication()->getDocument()->setTitle(
|
||||
Text::_('COM_MOKOSUITECROSS_CALENDAR') . ' - ' . Text::_('COM_MOKOSUITECROSS')
|
||||
);
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(
|
||||
Text::_('COM_MOKOSUITECROSS') . ' - ' . Text::_('COM_MOKOSUITECROSS_CALENDAR'),
|
||||
'calendar'
|
||||
);
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_mokosuitecross&view=dashboard', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Analytics\HtmlView $this */
|
||||
|
||||
$dayNames = [
|
||||
1 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN'),
|
||||
2 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_MON'),
|
||||
3 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE'),
|
||||
4 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_WED'),
|
||||
5 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_THU'),
|
||||
6 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI'),
|
||||
7 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT'),
|
||||
];
|
||||
?>
|
||||
<form method="get" class="mb-3">
|
||||
<input type="hidden" name="option" value="com_mokosuitecross" />
|
||||
<input type="hidden" name="view" value="analytics" />
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="analytics-period"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_PERIOD'); ?></label>
|
||||
<select name="period" id="analytics-period" class="form-select" onchange="this.form.submit();">
|
||||
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_7_DAYS'); ?></option>
|
||||
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_30_DAYS'); ?></option>
|
||||
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_90_DAYS'); ?></option>
|
||||
<option value="180" <?php echo $this->period == 180 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_180_DAYS'); ?></option>
|
||||
<option value="365" <?php echo $this->period == 365 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_365_DAYS'); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="analytics-service"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER'); ?></label>
|
||||
<select name="service_id" id="analytics-service" class="form-select" onchange="this.form.submit();">
|
||||
<option value="0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES'); ?></option>
|
||||
<?php foreach ($this->services as $svc) : ?>
|
||||
<option value="<?php echo (int) $svc['id']; ?>" <?php echo $this->serviceId == $svc['id'] ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($svc['title'] . ' (' . ucfirst($svc['service_type']) . ')'); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!empty($this->bestTimes)) : ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<?php foreach ($this->bestTimes as $bt) :
|
||||
$rate = (int) $bt['total'] > 0 ? round(((int) $bt['success'] / (int) $bt['total']) * 100) : 0;
|
||||
?>
|
||||
<div class="col-sm-6 col-md-4 col-lg mb-2">
|
||||
<div class="border rounded p-3 text-center h-100">
|
||||
<div class="fw-bold text-primary"><?php echo $dayNames[(int) $bt['dow']]; ?></div>
|
||||
<div class="display-6"><?php echo sprintf('%02d:00', (int) $bt['hour_of_day']); ?></div>
|
||||
<div class="text-muted small">
|
||||
<?php echo Text::sprintf('COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS', (int) $bt['success'], (int) $bt['total']); ?>
|
||||
</div>
|
||||
<span class="badge bg-success"><?php echo $rate; ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="alert alert-info mb-3">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HEATMAP'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body" style="overflow-x: auto;">
|
||||
<table class="table table-sm table-bordered text-center mb-0" style="min-width: 700px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<?php for ($h = 0; $h < 24; $h++) : ?>
|
||||
<th class="small" style="width: 3.8%;"><?php echo sprintf('%02d', $h); ?></th>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$maxTotal = 1;
|
||||
|
||||
foreach ($this->heatmap as $dayData) {
|
||||
foreach ($dayData as $cell) {
|
||||
if ($cell['total'] > $maxTotal) {
|
||||
$maxTotal = $cell['total'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->heatmap as $dow => $hours) : ?>
|
||||
<tr>
|
||||
<th class="text-nowrap small"><?php echo $dayNames[$dow]; ?></th>
|
||||
<?php foreach ($hours as $hour => $cell) :
|
||||
$intensity = $maxTotal > 0 ? $cell['total'] / $maxTotal : 0;
|
||||
$r = 255;
|
||||
$g = 255;
|
||||
$b = 255;
|
||||
|
||||
if ($cell['total'] > 0) {
|
||||
$rate = $cell['rate'];
|
||||
|
||||
if ($rate >= 80) {
|
||||
$r = (int) (255 - (155 * $intensity));
|
||||
$g = (int) (255 - (100 * $intensity));
|
||||
$b = (int) (255 - (155 * $intensity));
|
||||
} elseif ($rate >= 50) {
|
||||
$r = (int) (255 - (50 * $intensity));
|
||||
$g = (int) (255 - (50 * $intensity));
|
||||
$b = (int) (255 - (200 * $intensity));
|
||||
} else {
|
||||
$r = (int) (255 - (35 * $intensity));
|
||||
$g = (int) (255 - (200 * $intensity));
|
||||
$b = (int) (255 - (200 * $intensity));
|
||||
}
|
||||
}
|
||||
?>
|
||||
<td style="background: rgb(<?php echo "$r,$g,$b"; ?>); cursor: default;"
|
||||
title="<?php echo $dayNames[$dow] . ' ' . sprintf('%02d:00', $hour) . ': ' . $cell['total'] . ' posts, ' . $cell['success'] . ' success (' . $cell['rate'] . '%)'; ?>">
|
||||
<?php if ($cell['total'] > 0) : ?>
|
||||
<small><?php echo $cell['total']; ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex justify-content-center gap-3 mt-2 small text-muted">
|
||||
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(100,155,100); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH'); ?></span>
|
||||
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(205,205,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM'); ?></span>
|
||||
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(220,55,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW'); ?></span>
|
||||
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(255,255,255); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE'); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HOURLY'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="hourlyChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAILY'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="dayChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-UPIssOjNMqMfON6mDKHvO4sOY4hhxN1ymYcfl2MrDz69idMU/L3MNFlyJGlIRjQH" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var hourlyData = <?php echo json_encode(array_values($this->hourlyDistribution)); ?>;
|
||||
var hourLabels = [];
|
||||
var hourSuccess = [];
|
||||
var hourFailed = [];
|
||||
|
||||
for (var h = 0; h < 24; h++) {
|
||||
hourLabels.push(('0' + h).slice(-2) + ':00');
|
||||
var found = hourlyData.find(function(d) { return parseInt(d.hour_of_day, 10) === h; });
|
||||
hourSuccess.push(found ? parseInt(found.success, 10) : 0);
|
||||
hourFailed.push(found ? parseInt(found.failed, 10) : 0);
|
||||
}
|
||||
|
||||
new Chart(document.getElementById('hourlyChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hourLabels,
|
||||
datasets: [
|
||||
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_POSTED', true); ?>', data: hourSuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
|
||||
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_FAILED', true); ?>', data: hourFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
|
||||
var dayData = <?php echo json_encode(array_values($this->dayDistribution)); ?>;
|
||||
var dayLabels = <?php echo json_encode(array_values($dayNames)); ?>;
|
||||
var daySuccess = [];
|
||||
var dayFailed = [];
|
||||
|
||||
for (var d = 1; d <= 7; d++) {
|
||||
var found = dayData.find(function(r) { return parseInt(r.dow, 10) === d; });
|
||||
daySuccess.push(found ? parseInt(found.success, 10) : 0);
|
||||
dayFailed.push(found ? parseInt(found.failed, 10) : 0);
|
||||
}
|
||||
|
||||
new Chart(document.getElementById('dayChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: dayLabels,
|
||||
datasets: [
|
||||
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_POSTED', true); ?>', data: daySuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
|
||||
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_FAILED', true); ?>', data: dayFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @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
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
|
||||
|
||||
$token = Session::getFormToken();
|
||||
$ajaxUrl = $this->ajaxUrl;
|
||||
?>
|
||||
|
||||
<style>
|
||||
#mokosuitecross-calendar {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.mokosuitecross-calendar-legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mokosuitecross-calendar-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.mokosuitecross-calendar-legend .swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mokosuitecross-calendar-legend">
|
||||
<span><span class="swatch" style="background:#28a745;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_POSTED'); ?></span>
|
||||
<span><span class="swatch" style="background:#007bff;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_SCHEDULED'); ?></span>
|
||||
<span><span class="swatch" style="background:#ffc107;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_QUEUED'); ?></span>
|
||||
<span><span class="swatch" style="background:#dc3545;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_FAILED'); ?></span>
|
||||
</div>
|
||||
|
||||
<div id="mokosuitecross-calendar"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js" integrity="sha384-B1OFx8Gy9GjPu8UbUyXbGQpzll9ubAUQ9agInFJ8NnD7nYG1u/CLR+Sqr5yifl4q" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('mokosuitecross-calendar');
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,listWeek'
|
||||
},
|
||||
buttonText: {
|
||||
today: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY', true); ?>',
|
||||
month: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_MONTH', true); ?>',
|
||||
week: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_WEEK', true); ?>',
|
||||
list: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_LIST', true); ?>'
|
||||
},
|
||||
editable: true,
|
||||
droppable: false,
|
||||
navLinks: true,
|
||||
dayMaxEvents: true,
|
||||
eventSources: [{
|
||||
url: '<?php echo $ajaxUrl; ?>',
|
||||
method: 'GET',
|
||||
failure: function() {
|
||||
Joomla.renderMessages({
|
||||
error: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_LOAD_ERROR', true); ?>']
|
||||
});
|
||||
}
|
||||
}],
|
||||
eventClick: function(info) {
|
||||
info.jsEvent.preventDefault();
|
||||
if (info.event.url) {
|
||||
window.location.href = info.event.url;
|
||||
}
|
||||
},
|
||||
eventDrop: function(info) {
|
||||
var postId = info.event.id;
|
||||
var status = info.event.extendedProps.status;
|
||||
|
||||
// Only allow rescheduling of scheduled or queued posts
|
||||
if (status !== 'scheduled' && status !== 'queued') {
|
||||
info.revert();
|
||||
Joomla.renderMessages({
|
||||
warning: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_CANNOT_RESCHEDULE', true); ?>']
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var newDate = info.event.start.toISOString();
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('post_id', postId);
|
||||
formData.append('new_date', newDate);
|
||||
formData.append(token, '1');
|
||||
|
||||
fetch('index.php?option=com_mokosuitecross&task=calendar.reschedule&format=json', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
// Update the event colour to scheduled
|
||||
info.event.setProp('color', '#007bff');
|
||||
info.event.setExtendedProp('status', 'scheduled');
|
||||
Joomla.renderMessages({
|
||||
message: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS', true); ?>']
|
||||
});
|
||||
} else {
|
||||
info.revert();
|
||||
Joomla.renderMessages({
|
||||
error: [data.error || '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR', true); ?>']
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
info.revert();
|
||||
Joomla.renderMessages({
|
||||
error: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR', true); ?>']
|
||||
});
|
||||
});
|
||||
},
|
||||
eventDidMount: function(info) {
|
||||
// Add tooltip with post details
|
||||
var props = info.event.extendedProps;
|
||||
var tip = info.event.title;
|
||||
if (props.status) {
|
||||
tip += ' [' + props.status + ']';
|
||||
}
|
||||
if (props.message) {
|
||||
tip += '\n' + props.message;
|
||||
}
|
||||
info.el.setAttribute('title', tip);
|
||||
}
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
@@ -282,6 +282,10 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
|
||||
class="list-group-item list-group-item-action">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_LOGS'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar'); ?>"
|
||||
class="list-group-item list-group-item-action">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteCross</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -212,8 +212,100 @@ XML;
|
||||
|
||||
$form->load($xml);
|
||||
|
||||
// Cross-post history panel for existing articles
|
||||
// AI Generate button for the Share Content panel
|
||||
$articleId = Factory::getApplication()->input->getInt('id', 0);
|
||||
$aiParams = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$aiEnabled = \in_array($aiParams->get('ai_provider', 'none'), ['claude', 'openai'], true);
|
||||
|
||||
if ($aiEnabled && $articleId > 0) {
|
||||
$aiToken = Session::getFormToken();
|
||||
$aiUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=ai.generate&format=raw&article_id=' . $articleId . '&' . $aiToken . '=1';
|
||||
|
||||
$aiButtonHtml = '<div class="mb-3">'
|
||||
. '<button type="button" id="mokosuitecross-ai-btn" class="btn btn-sm btn-outline-info" onclick="mokosuitecrossAiGenerate()">'
|
||||
. '<span class="icon-magic" aria-hidden="true"></span> '
|
||||
. \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATE')
|
||||
. '</button>'
|
||||
. '<span id="mokosuitecross-ai-status" class="ms-2 small"></span>'
|
||||
. '</div>'
|
||||
. '<script>'
|
||||
. 'function mokosuitecrossAiGenerate(){'
|
||||
. 'var btn=document.getElementById("mokosuitecross-ai-btn");'
|
||||
. 'var st=document.getElementById("mokosuitecross-ai-status");'
|
||||
. 'btn.disabled=true;st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATING', true) . '";'
|
||||
. 'fetch("' . $aiUrl . '")'
|
||||
. '.then(function(r){return r.json();})'
|
||||
. '.then(function(d){'
|
||||
. 'btn.disabled=false;'
|
||||
. 'if(!d.success){st.textContent=d.error||"Error";return;}'
|
||||
. 'st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATED', true) . '";'
|
||||
. 'var f=d.data;'
|
||||
. 'var s=document.getElementById("jform_attribs_mokosuitecross_social_text");if(s)s.value=f.social;'
|
||||
. 'var h=document.getElementById("jform_attribs_mokosuitecross_short_text");if(h)h.value=f.short;'
|
||||
. 'var c=document.getElementById("jform_attribs_mokosuitecross_chat_text");if(c)c.value=f.chat;'
|
||||
. 'var e=document.getElementById("jform_attribs_mokosuitecross_email_subject");if(e)e.value=f.email_subject;'
|
||||
. '})'
|
||||
. '.catch(function(){btn.disabled=false;st.textContent="Request failed";});'
|
||||
. '}'
|
||||
. '</script>';
|
||||
|
||||
$aiXml = '<?xml version="1.0"?>
|
||||
<form><fields name="attribs"><fieldset name="mokosuitecross_share">
|
||||
<field name="mokosuitecross_ai_generate" type="note"
|
||||
label="" description="" />
|
||||
</fieldset></fields></form>';
|
||||
$form->load($aiXml);
|
||||
$form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs');
|
||||
}
|
||||
// Social Image Generator button (#157)
|
||||
$siParams = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$siEnabled = (bool) $siParams->get('social_image_enabled', 0);
|
||||
|
||||
if ($siEnabled && $articleId > 0) {
|
||||
$siToken = Session::getFormToken();
|
||||
$siUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=socialimage.generate&format=raw&article_id=' . $articleId . '&' . $siToken . '=1';
|
||||
|
||||
$siButtonHtml = '<div class="mb-3">'
|
||||
. '<button type="button" id="mokosuitecross-si-btn" class="btn btn-sm btn-outline-success" onclick="mokosuitecrossSiGenerate()">'
|
||||
. '<span class="icon-image" aria-hidden="true"></span> '
|
||||
. \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATE')
|
||||
. '</button>'
|
||||
. '<span id="mokosuitecross-si-status" class="ms-2 small"></span>'
|
||||
. '<div id="mokosuitecross-si-preview" class="mt-2" style="display:none;">'
|
||||
. '<img id="mokosuitecross-si-thumb" src="" alt="Social image preview" style="max-width:300px;border:1px solid #ccc;border-radius:4px;" />'
|
||||
. '</div>'
|
||||
. '</div>'
|
||||
. '<script>'
|
||||
. 'function mokosuitecrossSiGenerate(){'
|
||||
. 'var btn=document.getElementById("mokosuitecross-si-btn");'
|
||||
. 'var st=document.getElementById("mokosuitecross-si-status");'
|
||||
. 'var pv=document.getElementById("mokosuitecross-si-preview");'
|
||||
. 'var img=document.getElementById("mokosuitecross-si-thumb");'
|
||||
. 'btn.disabled=true;st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATING', true) . '";'
|
||||
. 'pv.style.display="none";'
|
||||
. 'fetch("' . $siUrl . '")'
|
||||
. '.then(function(r){return r.json();})'
|
||||
. '.then(function(d){'
|
||||
. 'btn.disabled=false;'
|
||||
. 'if(!d.success){st.textContent=d.error||"Error";return;}'
|
||||
. 'st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATED', true) . '";'
|
||||
. 'img.src="' . Uri::root() . '"+d.image_url+"?t="+Date.now();'
|
||||
. 'pv.style.display="block";'
|
||||
. '})'
|
||||
. '.catch(function(){btn.disabled=false;st.textContent="Request failed";});'
|
||||
. '}'
|
||||
. '</script>';
|
||||
|
||||
$siXml = '<?xml version="1.0"?>
|
||||
<form><fields name="attribs"><fieldset name="mokosuitecross_share">
|
||||
<field name="mokosuitecross_si_generate" type="note"
|
||||
label="" description="" />
|
||||
</fieldset></fields></form>';
|
||||
$form->load($siXml);
|
||||
$form->setFieldAttribute('mokosuitecross_si_generate', 'description', $siButtonHtml, 'attribs');
|
||||
}
|
||||
|
||||
// Cross-post history panel for existing articles
|
||||
|
||||
if ($articleId > 0) {
|
||||
$query = $db->getQuery(true)
|
||||
@@ -359,7 +451,7 @@ XML;
|
||||
/**
|
||||
* Dispatch cross-post when an article is saved and published.
|
||||
*/
|
||||
public function onContentAfterSave(\Joomla\CMS\Event\Content\AfterSaveEvent $event): void
|
||||
public function onContentAfterSave($event): void
|
||||
{
|
||||
$context = $event->getContext();
|
||||
|
||||
@@ -396,7 +488,7 @@ XML;
|
||||
/**
|
||||
* Dispatch cross-post when article state changes to published.
|
||||
*/
|
||||
public function onContentChangeState(\Joomla\CMS\Event\Content\ContentChangeStateEvent $event): void
|
||||
public function onContentChangeState($event): void
|
||||
{
|
||||
$context = $event->getContext();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - ActivityPub (Fediverse)</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Blogger</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Bluesky</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Brevo (Sendinblue)</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Constant Contact</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - ConvertKit</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Dev.to</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Discord</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Facebook / Meta</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Ghost</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Business Profile</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Chat</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Hashnode</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Instagram</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - LinkedIn</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Mailchimp</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Mastodon</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Matrix / Element</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Medium</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - MokoSuiteCalendar Events</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - MokoSuiteGallery</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Nostr</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Ntfy Push Notifications</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Pinterest</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Reddit</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - RSS Feed</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - SendGrid</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Slack</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Microsoft Teams</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Telegram</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Threads (Meta)</name>
|
||||
<version>01.08.44</version>
|
||||
<version>01.13.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - TikTok"
|
||||
PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok."
|
||||
PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok via Content Posting API. Supports video uploads (PULL_FROM_URL) and photo carousels (up to 35 images)."
|
||||
PLG_MOKOSUITECROSS_TIKTOK_AUDIT_WARNING="Unverified TikTok developer apps can only create private posts. To publish publicly, your app must pass TikTok's Content Posting API audit. Visit the TikTok Developer Portal to submit your app for review."
|
||||
|
||||
@@ -17,13 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* TikTok service plugin for MokoSuiteCross.
|
||||
*
|
||||
* API: https://open.tiktokapis.com/v2/post/publish/content/init/
|
||||
*/
|
||||
class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
private const API_BASE = 'https://open.tiktokapis.com/v2/post/publish/';
|
||||
private const MAX_PHOTO_IMAGES = 35;
|
||||
private const MAX_POLL_ATTEMPTS = 10;
|
||||
private const POLL_INTERVAL_SECONDS = 3;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
|
||||
@@ -47,28 +47,129 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']];
|
||||
}
|
||||
|
||||
if (empty($media[0])) {
|
||||
if (empty($media)) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']];
|
||||
}
|
||||
|
||||
$postData = json_encode([
|
||||
$postingMode = $params['posting_mode'] ?? 'DIRECT_POST';
|
||||
$privacyLevel = $params['privacy_level'] ?? 'SELF_ONLY';
|
||||
$caption = mb_substr($message, 0, 2200);
|
||||
$title = mb_substr(strip_tags($message), 0, 150);
|
||||
|
||||
if ($this->isVideoUrl($media[0])) {
|
||||
return $this->publishVideo($token, $title, $caption, $media[0], $postingMode, $privacyLevel);
|
||||
}
|
||||
|
||||
return $this->publishPhotos($token, $title, $caption, $media, $postingMode, $privacyLevel);
|
||||
}
|
||||
|
||||
private function publishVideo(string $token, string $title, string $caption, string $videoUrl, string $postingMode, string $privacyLevel): array
|
||||
{
|
||||
$payload = [
|
||||
'post_info' => [
|
||||
'title' => mb_substr(strip_tags($message), 0, 150),
|
||||
'description' => mb_substr($message, 0, 2200),
|
||||
'privacy_level' => 'SELF_ONLY',
|
||||
'title' => $title,
|
||||
'description' => $caption,
|
||||
'privacy_level' => $privacyLevel,
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $media[0],
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $videoUrl,
|
||||
],
|
||||
]);
|
||||
'post_mode' => $postingMode,
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
$result = $this->apiPost(self::API_BASE . 'video/init/', $token, $payload);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']];
|
||||
}
|
||||
|
||||
$publishId = $result['data']['data']['publish_id'] ?? '';
|
||||
|
||||
if (empty($publishId)) {
|
||||
return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']];
|
||||
}
|
||||
|
||||
return $this->pollPublishStatus($token, $publishId, $result['data']);
|
||||
}
|
||||
|
||||
private function publishPhotos(string $token, string $title, string $caption, array $media, string $postingMode, string $privacyLevel): array
|
||||
{
|
||||
$images = \array_slice($media, 0, self::MAX_PHOTO_IMAGES);
|
||||
|
||||
$photoImages = [];
|
||||
foreach ($images as $url) {
|
||||
$photoImages[] = ['image_url' => $url];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'post_info' => [
|
||||
'title' => $title,
|
||||
'description' => $caption,
|
||||
'privacy_level' => $privacyLevel,
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'photo_images' => $photoImages,
|
||||
],
|
||||
'post_mode' => $postingMode,
|
||||
'media_type' => 'PHOTO',
|
||||
];
|
||||
|
||||
$result = $this->apiPost(self::API_BASE . 'content/init/', $token, $payload);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']];
|
||||
}
|
||||
|
||||
$publishId = $result['data']['data']['publish_id'] ?? '';
|
||||
|
||||
if (empty($publishId)) {
|
||||
return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']];
|
||||
}
|
||||
|
||||
return $this->pollPublishStatus($token, $publishId, $result['data']);
|
||||
}
|
||||
|
||||
private function pollPublishStatus(string $token, string $publishId, array $initResponse): array
|
||||
{
|
||||
for ($i = 0; $i < self::MAX_POLL_ATTEMPTS; $i++) {
|
||||
sleep(self::POLL_INTERVAL_SECONDS);
|
||||
|
||||
$statusResult = $this->apiPost(self::API_BASE . 'status/fetch/', $token, [
|
||||
'publish_id' => $publishId,
|
||||
]);
|
||||
|
||||
if (!$statusResult['success']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $statusResult['data']['data']['status'] ?? '';
|
||||
|
||||
if ($status === 'PUBLISH_COMPLETE') {
|
||||
$postId = $statusResult['data']['data']['publicaly_available_post_id']
|
||||
?? $statusResult['data']['data']['post_id']
|
||||
?? $publishId;
|
||||
return ['success' => true, 'platform_post_id' => (string) $postId, 'response' => $statusResult['data']];
|
||||
}
|
||||
|
||||
if ($status === 'FAILED') {
|
||||
$failReason = $statusResult['data']['data']['fail_reason'] ?? 'Unknown error';
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => $failReason, 'data' => $statusResult['data']]];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'platform_post_id' => $publishId, 'response' => array_merge($initResponse, ['note' => 'Publish initiated but status polling timed out'])];
|
||||
}
|
||||
|
||||
private function apiPost(string $url, string $token, array $payload): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
@@ -77,24 +178,26 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
|
||||
return ['success' => false, 'data' => ['error' => 'Connection error: ' . $curlError]];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data];
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
return ['success' => false, 'data' => $data];
|
||||
}
|
||||
|
||||
private function isVideoUrl(string $url): bool
|
||||
{
|
||||
return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm|mkv)(\?|$)/i', $url);
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
@@ -129,6 +232,6 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
return ['image', 'video', 'carousel'];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user