Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a54b621f9d | |||
| 84513a81a5 | |||
| fceb3d5bf5 | |||
| 18e84fbcfe | |||
| 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 | |||
| a537132836 | |||
| 6f29c077e2 | |||
| 9fa2560ce4 | |||
| 45afb1f0b1 | |||
| 843c729828 | |||
| db061e2b75 | |||
| 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"
|
||||
|
||||
@@ -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:
|
||||
@@ -101,7 +102,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 +121,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
|
||||
@@ -268,7 +269,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 +294,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 +363,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 +392,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 +416,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 +437,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 +463,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.42
|
||||
# VERSION: 01.00.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: |
|
||||
|
||||
+28
-57
@@ -1,7 +1,26 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.11.00] --- 2026-06-28
|
||||
|
||||
## [01.11.00] --- 2026-06-28
|
||||
|
||||
## [01.11.00] --- 2026-06-28
|
||||
|
||||
## [01.11.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 Open Graph images with article title overlay using PHP GD library (#157)
|
||||
- **Social image config**: Background color, text color, overlay style, and site name override in component options (#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,8 +40,17 @@
|
||||
- **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
|
||||
- **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 +65,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.42 -->
|
||||
|
||||
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.42
|
||||
VERSION: 01.11.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,6 +1,6 @@
|
||||
# MokoSuiteCross
|
||||
|
||||
<!-- VERSION: 01.08.42 -->
|
||||
<!-- VERSION: 01.11.00 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
|
||||
@@ -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.42
|
||||
VERSION: 01.11.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 |
|
||||
|
||||
@@ -120,6 +120,42 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="link_shortening" label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING">
|
||||
<field
|
||||
name="link_shortener"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC"
|
||||
default="none">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE</option>
|
||||
<option value="bitly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY</option>
|
||||
<option value="rebrandly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY</option>
|
||||
<option value="yourls">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS</option>
|
||||
</field>
|
||||
<field
|
||||
name="link_shortener_api_key"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC"
|
||||
showon="link_shortener:bitly,rebrandly"
|
||||
/>
|
||||
<field
|
||||
name="link_shortener_yourls_url"
|
||||
type="url"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC"
|
||||
hint="https://short.example.com/yourls-api.php"
|
||||
showon="link_shortener:yourls"
|
||||
/>
|
||||
<field
|
||||
name="link_shortener_yourls_token"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC"
|
||||
showon="link_shortener:yourls"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
|
||||
<field
|
||||
name="evergreen_enabled"
|
||||
@@ -191,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"
|
||||
|
||||
@@ -534,7 +534,76 @@ COM_MOKOSUITECROSS_DISPATCH_INVALID_SERVICES="service_ids must be a non-empty ar
|
||||
COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found."
|
||||
COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request."
|
||||
|
||||
; Link Shortening
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING="Link Shortening"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER="Link Shortener"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC="Select a link shortening service. Shortened URLs are available via the {url_short} placeholder in templates."
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE="None (disabled)"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY="Bitly"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY="Rebrandly"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS="YOURLS (self-hosted)"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY="API Key"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC="API key for Bitly or Rebrandly."
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL="YOURLS API URL"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC="Full URL to your YOURLS API endpoint (e.g. https://short.example.com/yourls-api.php)."
|
||||
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."
|
||||
|
||||
; Analytics
|
||||
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Time Period"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER="Service"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HOURLY="Hourly Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAILY="Day of Week Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough posting data to generate recommendations. Post at least 3 times per time slot over the selected period."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS="%d of %d successful"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN="Sun"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_MON="Mon"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE="Tue"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_WED="Wed"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_THU="Thu"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI="Fri"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT="Sat"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH="High success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM="Medium success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW="Low success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data"
|
||||
COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days"
|
||||
COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days"
|
||||
|
||||
; 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."
|
||||
|
||||
; Calendar View
|
||||
COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH="Previous"
|
||||
COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH="Next"
|
||||
COM_MOKOSUITECROSS_CALENDAR_TODAY="Today"
|
||||
COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Post Calendar"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokosuitecross</name>
|
||||
<version>01.08.42</version>
|
||||
<version>01.11.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
; MokoSuiteCross — Site Frontend Language File
|
||||
; MokoSuiteCross -- Site Frontend Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOSUITECROSS="MokoSuiteCross"
|
||||
COM_MOKOSUITECROSS_POSTS_LIST_TITLE="Cross-Posted Content"
|
||||
COM_MOKOSUITECROSS_POST_DETAIL_TITLE="Cross-Post History"
|
||||
COM_MOKOSUITECROSS_COLUMN_ARTICLE="Article"
|
||||
COM_MOKOSUITECROSS_COLUMN_PLATFORMS="Platforms"
|
||||
COM_MOKOSUITECROSS_COLUMN_LAST_POSTED="Last Posted"
|
||||
COM_MOKOSUITECROSS_COLUMN_STATUS="Status"
|
||||
COM_MOKOSUITECROSS_COLUMN_POSTED_DATE="Posted Date"
|
||||
COM_MOKOSUITECROSS_COLUMN_LINK="Platform Link"
|
||||
COM_MOKOSUITECROSS_NO_POSTS="No cross-posted content found."
|
||||
|
||||
@@ -17,5 +17,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'post';
|
||||
protected $default_view = 'posts';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class PostModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getArticle(int $articleId): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('a.id, a.title, a.alias, a.catid, a.access')
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->where('a.id = ' . (int) $articleId)
|
||||
->where('a.state = 1');
|
||||
|
||||
$groups = $user->getAuthorisedViewLevels();
|
||||
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObject() ?: null;
|
||||
}
|
||||
|
||||
public function getPosts(int $articleId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'p.id',
|
||||
'p.status',
|
||||
'p.platform_post_id',
|
||||
'p.posted_at',
|
||||
'p.error_message',
|
||||
'p.created',
|
||||
's.title AS service_title',
|
||||
's.service_type',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
|
||||
->where('p.article_id = ' . (int) $articleId)
|
||||
->order('p.created DESC');
|
||||
|
||||
$db->setQuery($query, 0, 50);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
|
||||
class PostsModel extends ListModel
|
||||
{
|
||||
protected function getListQuery()
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select([
|
||||
'a.id AS article_id',
|
||||
'a.title AS article_title',
|
||||
'a.alias AS article_alias',
|
||||
'a.catid',
|
||||
'MAX(p.posted_at) AS last_posted',
|
||||
'COUNT(p.id) AS post_count',
|
||||
'GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR \',\') AS service_types',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__content', 'a') . ' ON a.id = p.article_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
|
||||
->where('p.status = ' . $db->quote('posted'))
|
||||
->where('a.state = 1');
|
||||
|
||||
// Access filtering
|
||||
$groups = $user->getAuthorisedViewLevels();
|
||||
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
|
||||
|
||||
$query->group('a.id, a.title, a.alias, a.catid')
|
||||
->order('last_posted DESC');
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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\Site\View\Post;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $article;
|
||||
protected $posts;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$articleId = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$model = $this->getModel();
|
||||
$this->article = $model->getArticle($articleId);
|
||||
$this->posts = $model->getPosts($articleId);
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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\Site\View\Posts;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?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\Router\Route;
|
||||
|
||||
$statusClasses = [
|
||||
'posted' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
'permanently_failed' => 'bg-danger',
|
||||
'queued' => 'bg-warning text-dark',
|
||||
'posting' => 'bg-info',
|
||||
'scheduled' => 'bg-primary',
|
||||
'deleted' => 'bg-secondary',
|
||||
'cancelled' => 'bg-secondary',
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="com-mokosuitecross-post">
|
||||
<?php if (!$this->article) : ?>
|
||||
<div class="alert alert-warning">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POST_DETAIL_TITLE'); ?></h2>
|
||||
<p>
|
||||
<strong><?php echo $this->escape($this->article->title); ?></strong>
|
||||
</p>
|
||||
|
||||
<?php if (empty($this->posts)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_HEADING_SERVICE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_STATUS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_POSTED_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LINK'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->posts as $post) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($post->service_type); ?></span>
|
||||
<?php echo $this->escape($post->service_title); ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?php echo $statusClasses[$post->status] ?? 'bg-secondary'; ?>">
|
||||
<?php echo $this->escape(ucfirst($post->status)); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo $post->posted_at ? $this->escape($post->posted_at) : $this->escape($post->created); ?></td>
|
||||
<td>
|
||||
<?php if (!empty($post->platform_post_id)) : ?>
|
||||
<span class="text-muted small"><?php echo $this->escape($post->platform_post_id); ?></span>
|
||||
<?php else : ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=posts'); ?>" class="btn btn-secondary">
|
||||
← <?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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\Router\Route;
|
||||
|
||||
?>
|
||||
<div class="com-mokosuitecross-posts">
|
||||
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?></h2>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_ARTICLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_PLATFORMS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LAST_POSTED'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $item) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=post&id=' . (int) $item->article_id); ?>">
|
||||
<?php echo $this->escape($item->article_title); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$types = explode(',', $item->service_types ?? '');
|
||||
foreach ($types as $type) :
|
||||
$type = trim($type);
|
||||
if (empty($type)) continue;
|
||||
?>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $item->last_posted ? $this->escape($item->last_posted) : '—'; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($this->pagination->pagesTotal > 1) : ?>
|
||||
<div class="com-mokosuitecross-posts__pagination">
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.42 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.43 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.44 — no schema changes */
|
||||
@@ -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 @@
|
||||
/* 01.08.54 — no schema changes */
|
||||
@@ -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.61 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.11.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,24 @@
|
||||
<?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;
|
||||
|
||||
class AnalyticsController extends BaseController
|
||||
{
|
||||
public function display($cachable = false, $urlparams = []): static
|
||||
{
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
|
||||
class CalendarController extends BaseController
|
||||
{
|
||||
public function display($cachable = false, $urlparams = []): static
|
||||
{
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
}
|
||||
@@ -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,98 @@
|
||||
<?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\CMS\Uri\Uri;
|
||||
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.manage', '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', 'images']))
|
||||
->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;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$siteName = $params->get('social_image_site_name', '') ?: Factory::getApplication()->get('sitename', '');
|
||||
|
||||
$options = [
|
||||
'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
|
||||
'text_color' => $params->get('social_image_text_color', '#ffffff'),
|
||||
'overlay' => $params->get('social_image_overlay', 'dark'),
|
||||
];
|
||||
|
||||
$backgroundPath = null;
|
||||
$images = json_decode($article->images ?? '{}', true);
|
||||
|
||||
if (!empty($images['image_intro'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_intro'], '/');
|
||||
} elseif (!empty($images['image_fulltext'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_fulltext'], '/');
|
||||
}
|
||||
|
||||
try {
|
||||
$imagePath = SocialImageHelper::generate($article->title, $siteName, $backgroundPath, $options);
|
||||
$imageUrl = str_replace(JPATH_ROOT, Uri::root(true), str_replace('\\', '/', $imagePath));
|
||||
|
||||
$result = ['success' => true, 'image_url' => $imageUrl, 'image_path' => $imagePath];
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$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,160 @@
|
||||
<?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
|
||||
{
|
||||
private static array $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
public static function getPostingHeatmap(string $serviceType = '', int $days = 90): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS dow')
|
||||
->select('HOUR(' . $db->quoteName('p.posted_at') . ') AS hr')
|
||||
->select('COUNT(*) AS cnt')
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
|
||||
->where($db->quoteName('p.posted_at') . ' IS NOT NULL');
|
||||
|
||||
if ($days > 0) {
|
||||
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($since));
|
||||
}
|
||||
|
||||
if ($serviceType !== '') {
|
||||
$query->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
|
||||
->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType));
|
||||
}
|
||||
|
||||
$query->group('dow, hr')
|
||||
->order('dow ASC, hr ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList();
|
||||
|
||||
$grid = [];
|
||||
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
$grid[$d] = array_fill(0, 24, 0);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$grid[(int) $row->dow][(int) $row->hr] = (int) $row->cnt;
|
||||
}
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
public static function getBestTimes(string $serviceType = '', int $days = 90, int $limit = 5): array
|
||||
{
|
||||
$grid = self::getPostingHeatmap($serviceType, $days);
|
||||
$slots = [];
|
||||
|
||||
foreach ($grid as $dow => $hours) {
|
||||
foreach ($hours as $hour => $count) {
|
||||
if ($count > 0) {
|
||||
$slots[] = [
|
||||
'day' => self::$dayNames[$dow],
|
||||
'hour' => $hour,
|
||||
'count' => $count,
|
||||
'label' => self::$dayNames[$dow] . ' ' . self::formatHour($hour),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($slots, static fn($a, $b) => $b['count'] <=> $a['count']);
|
||||
|
||||
return \array_slice($slots, 0, $limit);
|
||||
}
|
||||
|
||||
public static function getServiceBreakdown(int $days = 30): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('s.service_type'))
|
||||
->select($db->quoteName('s.title', 'service_title'))
|
||||
->select('COUNT(*) AS total')
|
||||
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success')
|
||||
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed')
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'));
|
||||
|
||||
if ($days > 0) {
|
||||
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since));
|
||||
}
|
||||
|
||||
$query->group($db->quoteName(['s.service_type', 's.title']))
|
||||
->order('total DESC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList();
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total = (int) $row->total;
|
||||
$success = (int) $row->success;
|
||||
$result[] = [
|
||||
'service_type' => $row->service_type,
|
||||
'service_title' => $row->service_title,
|
||||
'total' => $total,
|
||||
'success' => $success,
|
||||
'failed' => (int) $row->failed,
|
||||
'success_rate' => $total > 0 ? round(($success / $total) * 100, 1) : 0.0,
|
||||
'avg_per_day' => $days > 0 ? round($total / $days, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function getServiceTypes(): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('service_type'))
|
||||
->from($db->quoteName('#__mokosuitecross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('service_type') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadColumn() ?: [];
|
||||
}
|
||||
|
||||
private static function formatHour(int $hour): string
|
||||
{
|
||||
if ($hour === 0) {
|
||||
return '12:00 AM';
|
||||
}
|
||||
|
||||
if ($hour < 12) {
|
||||
return $hour . ':00 AM';
|
||||
}
|
||||
|
||||
if ($hour === 12) {
|
||||
return '12:00 PM';
|
||||
}
|
||||
|
||||
return ($hour - 12) . ':00 PM';
|
||||
}
|
||||
}
|
||||
@@ -477,12 +477,16 @@ class CrossPostDispatcher
|
||||
$url = $url . $separator . http_build_query($utmParams);
|
||||
}
|
||||
|
||||
// Link shortening (#159) — shorten the final URL (with UTM if enabled)
|
||||
$urlShort = LinkShortenerHelper::shorten($url);
|
||||
|
||||
return [
|
||||
'{title}' => $titleText,
|
||||
'{introtext}' => $introStripped,
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{url_raw}' => $urlRaw,
|
||||
'{url_short}' => $urlShort,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?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\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Shortens URLs via Bitly, Rebrandly, or YOURLS.
|
||||
*
|
||||
* Returns the original URL on any failure so cross-posts are never broken.
|
||||
*/
|
||||
class LinkShortenerHelper
|
||||
{
|
||||
/**
|
||||
* Shorten a URL using the configured provider.
|
||||
*
|
||||
* @param string $url The URL to shorten
|
||||
*
|
||||
* @return string Shortened URL, or the original on failure/disabled
|
||||
*/
|
||||
public static function shorten(string $url): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$provider = $params->get('link_shortener', 'none');
|
||||
|
||||
if ($provider === 'none' || empty($url)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$apiKey = $params->get('link_shortener_api_key', '');
|
||||
|
||||
switch ($provider) {
|
||||
case 'bitly':
|
||||
return self::shortenWithBitly($url, $apiKey);
|
||||
|
||||
case 'rebrandly':
|
||||
return self::shortenWithRebrandly($url, $apiKey);
|
||||
|
||||
case 'yourls':
|
||||
$apiUrl = $params->get('link_shortener_yourls_url', '');
|
||||
$token = $params->get('link_shortener_yourls_token', '');
|
||||
return self::shortenWithYourls($url, $apiUrl, $token);
|
||||
|
||||
default:
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via Bitly API v4.
|
||||
*/
|
||||
public static function shortenWithBitly(string $url, string $apiKey): string
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api-ssl.bitly.com/v4/shorten',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['long_url' => $url]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['link'] ?? $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via Rebrandly API.
|
||||
*/
|
||||
public static function shortenWithRebrandly(string $url, string $apiKey, string $workspace = ''): string
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'apikey: ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
if (!empty($workspace)) {
|
||||
$headers[] = 'workspace: ' . $workspace;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api.rebrandly.com/v1/links',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['destination' => $url]),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$short = $data['shortUrl'] ?? '';
|
||||
|
||||
return !empty($short) ? 'https://' . ltrim($short, 'https://') : $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via YOURLS API (self-hosted).
|
||||
*/
|
||||
public static function shortenWithYourls(string $url, string $apiUrl, string $signatureToken): string
|
||||
{
|
||||
if (empty($apiUrl) || empty($signatureToken)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$endpoint = rtrim($apiUrl, '/') . '?' . http_build_query([
|
||||
'action' => 'shorturl',
|
||||
'format' => 'json',
|
||||
'signature' => $signatureToken,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['shorturl'] ?? $url;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ class MokoSuiteCrossHelper
|
||||
'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES',
|
||||
'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES',
|
||||
'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,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\Calendar;
|
||||
|
||||
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 int $year;
|
||||
public int $month;
|
||||
public array $events;
|
||||
public $sidebar;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$input = Factory::getApplication()->input;
|
||||
|
||||
$this->year = $input->getInt('year', (int) date('Y'));
|
||||
$this->month = $input->getInt('month', (int) date('n'));
|
||||
|
||||
if ($this->month < 1 || $this->month > 12) {
|
||||
$this->month = (int) date('n');
|
||||
}
|
||||
|
||||
if ($this->year < 2000 || $this->year > 2100) {
|
||||
$this->year = (int) date('Y');
|
||||
}
|
||||
|
||||
$model = $this->getModel();
|
||||
$this->events = $model->getEvents($this->year, $this->month);
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
MokoSuiteCrossHelper::addSubmenu('calendar');
|
||||
$this->sidebar = \Joomla\CMS\HTML\Sidebar::render();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross -- Post Calendar', 'calendar');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitecross&view=dashboard');
|
||||
|
||||
if ($canDo->get('core.admin')) {
|
||||
ToolbarHelper::preferences('com_mokosuitecross');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,129 @@
|
||||
<?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\Router\Route;
|
||||
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
|
||||
|
||||
$year = $this->year;
|
||||
$month = $this->month;
|
||||
$events = $this->events;
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$prevMonth = $month - 1;
|
||||
$prevYear = $year;
|
||||
|
||||
if ($prevMonth < 1) {
|
||||
$prevMonth = 12;
|
||||
$prevYear--;
|
||||
}
|
||||
|
||||
$nextMonth = $month + 1;
|
||||
$nextYear = $year;
|
||||
|
||||
if ($nextMonth > 12) {
|
||||
$nextMonth = 1;
|
||||
$nextYear++;
|
||||
}
|
||||
|
||||
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
|
||||
$daysInMonth = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$firstWeekday = ((int) date('N', mktime(0, 0, 0, $month, 1, $year))) - 1;
|
||||
|
||||
$statusClass = static function (string $status): string {
|
||||
return match ($status) {
|
||||
'posted' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
default => 'bg-warning text-dark',
|
||||
};
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $prevYear . '&month=' . $prevMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<span class="icon-chevron-left" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH'); ?>
|
||||
</a>
|
||||
<h3 class="mb-0"><?php echo htmlspecialchars($monthName . ' ' . $year); ?></h3>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $nextYear . '&month=' . $nextMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH'); ?>
|
||||
<span class="icon-chevron-right" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:14.28%"><?php echo Text::_('MON'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('TUE'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('WED'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('THU'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('FRI'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SAT'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SUN'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$day = 1;
|
||||
$started = false;
|
||||
|
||||
while ($day <= $daysInMonth) : ?>
|
||||
<tr>
|
||||
<?php for ($col = 0; $col < 7; $col++) :
|
||||
if (!$started && $col < $firstWeekday) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
|
||||
$started = true;
|
||||
|
||||
if ($day > $daysInMonth) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
|
||||
$dateKey = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$isToday = ($dateKey === $today);
|
||||
$cellClass = $isToday ? 'border border-primary border-2 bg-primary bg-opacity-10' : '';
|
||||
$dayEvents = $events[$dateKey] ?? [];
|
||||
?>
|
||||
<td class="<?php echo $cellClass; ?>" style="vertical-align: top; min-height: 80px;">
|
||||
<div class="fw-bold mb-1<?php echo $isToday ? ' text-primary' : ''; ?>">
|
||||
<?php echo $day; ?>
|
||||
<?php if ($isToday) : ?>
|
||||
<small class="text-primary"><?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY'); ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php foreach ($dayEvents as $event) : ?>
|
||||
<span class="badge <?php echo $statusClass($event->status); ?> mb-1 d-block text-truncate" style="max-width: 100%;"
|
||||
title="<?php echo htmlspecialchars(ucfirst($event->service_type) . ': ' . $event->article_title . ' (' . $event->status . ')'); ?>">
|
||||
<?php echo htmlspecialchars(ucfirst($event->service_type)); ?>:
|
||||
<?php echo htmlspecialchars(mb_substr($event->article_title, 0, 20)); ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<?php
|
||||
$day++;
|
||||
endfor; ?>
|
||||
</tr>
|
||||
<?php endwhile; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -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=analytics'); ?>"
|
||||
class="list-group-item list-group-item-action">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_ANALYTICS'); ?>
|
||||
</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.42</version>
|
||||
<version>01.11.00</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -212,8 +212,53 @@ 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');
|
||||
}
|
||||
|
||||
// Cross-post history panel for existing articles
|
||||
|
||||
if ($articleId > 0) {
|
||||
$query = $db->getQuery(true)
|
||||
@@ -359,7 +404,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 +441,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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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.42</version>
|
||||
<version>01.11.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."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user