Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50412f33ba | |||
| c54416f06e | |||
| 434505fd0b | |||
| 148e133fc3 | |||
| f660899677 | |||
| 116896b584 | |||
| eaf99d3743 | |||
| 701b64f5c2 |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mokoplatform xmlns="https://standards.mokoconsulting.tech/mokoplatform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>MokoSuiteCross</name>
|
||||
<display-name>Package - MokoSuiteCross</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms</description>
|
||||
<version>01.02.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
<platform>joomla</platform>
|
||||
<standards-version>05.00.00</standards-version>
|
||||
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/mokoplatform</standards-source>
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>source/</entry-point>
|
||||
</build>
|
||||
<licensing>
|
||||
<enabled>true</enabled>
|
||||
<dlid>true</dlid>
|
||||
<update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||
</licensing>
|
||||
</mokoplatform>
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
@@ -43,19 +43,19 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
- name: Setup moko-platform 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"
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/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"
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# INGROUP: mokoplatform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
@@ -21,7 +21,7 @@
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
@@ -30,15 +30,6 @@ on:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
@@ -60,7 +51,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
@@ -75,25 +66,25 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
- name: Setup mokoplatform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokoplatform
|
||||
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
rm -rf /tmp/mokoplatform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
|
||||
cd /tmp/mokoplatform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rename branch to rc
|
||||
@@ -118,47 +109,13 @@ jobs:
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "RC release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
@@ -192,131 +149,50 @@ jobs:
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup mokocli tools
|
||||
- name: Setup mokoplatform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokoplatform
|
||||
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
rm -rf /tmp/mokoplatform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
|
||||
cd /tmp/mokoplatform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: "Detect platform"
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||
# Feature/dev branches: bump minor for the new stable release
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
case "$HEAD_REF" in
|
||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||
*) BUMP="minor" ;;
|
||||
esac
|
||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
BUMP_FLAG=""
|
||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||
fi
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: "Read published version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||
VERSION=$(python3 -c "
|
||||
import json, sys, re
|
||||
r = json.load(sys.stdin)
|
||||
name = r.get('name', '')
|
||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||
print(m.group(1) if m else '')
|
||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
@@ -326,7 +202,7 @@ jobs:
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
@@ -334,24 +210,6 @@ jobs:
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
content = open('CHANGELOG.md').read()
|
||||
old = '## [Unreleased]'
|
||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||
content = content.replace(old, new, 1)
|
||||
open('CHANGELOG.md', 'w').write(content)
|
||||
" "$VERSION" "$DATE"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||
git push origin main || true
|
||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
- version/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -45,17 +45,17 @@ jobs:
|
||||
fi
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup mokocli tools
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||
echo "mokocli already available on runner — skipping clone"
|
||||
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
|
||||
echo "moko-platform already available on runner — skipping clone"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -164,75 +164,6 @@ jobs:
|
||||
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Update server & packaging checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Update Server & Packaging" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
# Find the extension manifest
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
|
||||
# 1. Check <updateservers> exists and uses MokoGitea update server
|
||||
if ! grep -q '<updateservers>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<updateservers>\` tag — extension will not receive OTA updates"
|
||||
echo "- **Missing** \`<updateservers>\` — extension will not receive OTA updates" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
SERVER_URL=$(grep -oP '<server[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$SERVER_URL" ]; then
|
||||
echo "::warning file=${MANIFEST}::\`<updateservers>\` is empty — no server URL defined"
|
||||
echo "- **Empty** \`<updateservers>\` — no server URL defined" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
elif ! echo "$SERVER_URL" | grep -q 'git\.mokoconsulting\.tech'; then
|
||||
echo "::warning file=${MANIFEST}::Update server does not use MokoGitea engine: ${SERVER_URL}"
|
||||
echo "- **Non-MokoGitea update server:** \`${SERVER_URL}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo " Expected: \`https://git.mokoconsulting.tech/{org}/{repo}/updates.xml\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<updateservers>\`: MokoGitea engine ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Check <dlid> tag exists
|
||||
if ! grep -q '<dlid' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<dlid>\` tag — download ID authentication is not configured"
|
||||
echo "- **Missing** \`<dlid>\` — download ID authentication not configured" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<dlid>\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# 3. For packages: check <childuninstall> tag
|
||||
if [ "$EXT_TYPE" = "package" ]; then
|
||||
if ! grep -q '<childuninstall>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Package is missing \`<childuninstall>\` — child extensions will not be removed on uninstall"
|
||||
echo "- **Missing** \`<childuninstall>\` — child extensions will remain when package is uninstalled" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<childuninstall>\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} packaging warning(s).** These won't block CI but should be addressed." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Update server & packaging checks passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check language files referenced in manifest
|
||||
run: |
|
||||
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -314,675 +245,10 @@ jobs:
|
||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check config.xml and access.xml for components
|
||||
run: |
|
||||
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find all component manifests (XML with type="component")
|
||||
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$COMP_MANIFESTS" ]; then
|
||||
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $COMP_MANIFESTS; do
|
||||
COMP_DIR=$(dirname "$MANIFEST")
|
||||
COMP_NAME=$(basename "$COMP_DIR")
|
||||
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check access.xml exists
|
||||
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
for ACTION in core.admin core.manage; do
|
||||
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check config.xml exists
|
||||
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$CONFIG_FILE" ]; then
|
||||
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SQL schema validation
|
||||
run: |
|
||||
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find SQL files in source/htdocs
|
||||
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$SQL_FILES" ]; then
|
||||
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $SQL_FILES; do
|
||||
# Basic syntax check: balanced parentheses, no empty files
|
||||
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||
if [ "$SIZE" -eq 0 ]; then
|
||||
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for common SQL errors
|
||||
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
|
||||
# Check update SQL files follow version numbering pattern
|
||||
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -n "$UPDATE_DIR" ]; then
|
||||
BAD_NAMES=0
|
||||
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||
[ ! -f "$UFILE" ] && continue
|
||||
BASENAME=$(basename "$UFILE" .sql)
|
||||
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||
BAD_NAMES=$((BAD_NAMES + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||
ERRORS=$((ERRORS + BAD_NAMES))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Manifest file references check
|
||||
run: |
|
||||
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <filename> references
|
||||
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILENAMES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <folder> references
|
||||
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Form XML validation
|
||||
run: |
|
||||
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$FORM_FILES" ]; then
|
||||
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
for FILE in $FORM_FILES; do
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
# Check for valid Joomla form structure
|
||||
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Deprecated Joomla API check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||
PATTERNS=(
|
||||
'JFactory::'
|
||||
'JText::'
|
||||
'JHtml::'
|
||||
'JRoute::'
|
||||
'JUri::'
|
||||
'JLog::'
|
||||
'JTable::'
|
||||
'JInput'
|
||||
'CMSFactory::\$application'
|
||||
'JApplicationCms'
|
||||
)
|
||||
|
||||
for PATTERN in "${PATTERNS[@]}"; do
|
||||
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
COUNT=$(echo "$HITS" | wc -l)
|
||||
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Template output escaping check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$TMPL_FILES" ]; then
|
||||
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $TMPL_FILES; do
|
||||
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$UNESCAPED" ]; then
|
||||
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
|
||||
# Check for echo without escaping in template context
|
||||
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$RAW_ECHO" ]; then
|
||||
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Namespace consistency check
|
||||
run: |
|
||||
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find component/plugin manifests with <namespace> tags
|
||||
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $MANIFESTS; do
|
||||
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$NS_PATH" ] && continue
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check PHP files have matching namespace
|
||||
while IFS= read -r -d '' PHP_FILE; do
|
||||
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_NS" ] && continue
|
||||
|
||||
# Namespace should start with the manifest namespace path
|
||||
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SPDX license header check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL=0
|
||||
while IFS= read -r -d '' FILE; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Service provider check
|
||||
run: |
|
||||
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$PROVIDERS" ]; then
|
||||
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for FILE in $PROVIDERS; do
|
||||
# Must return a ServiceProviderInterface
|
||||
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Must have return statement
|
||||
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Script file reference check
|
||||
run: |
|
||||
echo "### Script File Reference" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
SCRIPT_FILE=$(grep -oP '<scriptfile>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$SCRIPT_FILE" ]; then
|
||||
echo "No \`<scriptfile>\` referenced — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ ! -f "${MANIFEST_DIR}/${SCRIPT_FILE}" ]; then
|
||||
echo "::error file=${MANIFEST}::Manifest references \`<scriptfile>${SCRIPT_FILE}</scriptfile>\` but file does not exist"
|
||||
echo "- **Missing** \`${SCRIPT_FILE}\` — referenced in \`<scriptfile>\` but not found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${SCRIPT_FILE}\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} script file issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Script file reference check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Media folder validation
|
||||
run: |
|
||||
echo "### Media Folder Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <media> tag and its folder/filename children
|
||||
MEDIA_DEST=$(grep -oP '<media[^>]*\bdestination="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
MEDIA_FOLDER=$(grep -oP '<media[^>]*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$MEDIA_DEST" ] && [ -z "$MEDIA_FOLDER" ]; then
|
||||
echo "No \`<media>\` tag found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
if [ -n "$MEDIA_FOLDER" ] && [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then
|
||||
echo "::error file=${MANIFEST}::\`<media folder=\"${MEDIA_FOLDER}\">\` references missing directory"
|
||||
echo "- **Missing** media folder \`${MEDIA_FOLDER}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- Media folder \`${MEDIA_FOLDER:-(inline)}\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check child references inside <media> block
|
||||
if [ -n "$MEDIA_FOLDER" ]; then
|
||||
MEDIA_FOLDERS=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<folder>\K[^<]+' 2>/dev/null || true)
|
||||
for F in $MEDIA_FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
|
||||
echo "- **Missing** media subfolder \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
MEDIA_FILES=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<filename>\K[^<]+' 2>/dev/null || true)
|
||||
for F in $MEDIA_FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
|
||||
echo "- **Missing** media file \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} media reference issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Media folder validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Target platform check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Target Platform Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Check updates.xml for targetplatform if it exists
|
||||
if [ -f "updates.xml" ]; then
|
||||
if ! grep -q '<targetplatform' "updates.xml" 2>/dev/null; then
|
||||
echo "::warning file=updates.xml::No \`<targetplatform>\` found — Joomla updater cannot filter by compatible version"
|
||||
echo "- **Missing** \`<targetplatform>\` in updates.xml" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<targetplatform>\` in updates.xml: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check manifest for minimum PHP/Joomla version hints
|
||||
if ! grep -qP '<php_minimum>|targetplatform|joomla.*version' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::No minimum Joomla or PHP version constraint found in manifest"
|
||||
echo "- **Missing** version constraints (\`<php_minimum>\` or \`<targetplatform>\`)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- Version constraints in manifest: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} target platform warning(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Target platform check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Changelog URL check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Changelog URL Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
if ! grep -q '<changelogurl>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<changelogurl>\` — Joomla updater will not display changelogs"
|
||||
echo "- **Missing** \`<changelogurl>\` — Joomla 4+ shows changelogs in the update manager when this is set" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
CHANGELOG_URL=$(grep -oP '<changelogurl>\K[^<]+' "$MANIFEST" | head -1)
|
||||
echo "- \`<changelogurl>\`: \`${CHANGELOG_URL}\` ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} changelog URL warning(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Changelog URL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Duplicate file references check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Duplicate File References" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Extract all <filename> and <folder> references
|
||||
ALL_REFS=$(grep -oP '<(filename|folder)[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | sort || true)
|
||||
if [ -z "$ALL_REFS" ]; then
|
||||
echo "No file/folder references found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
DUPES=$(echo "$ALL_REFS" | uniq -d)
|
||||
if [ -n "$DUPES" ]; then
|
||||
while IFS= read -r DUP; do
|
||||
COUNT=$(echo "$ALL_REFS" | grep -cx "$DUP")
|
||||
echo "::warning file=${MANIFEST}::Duplicate reference: \`${DUP}\` appears ${COUNT} times (may be valid if in different sections)"
|
||||
echo "- **Duplicate:** \`${DUP}\` (${COUNT}x) — check if cross-section" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
done <<< "$DUPES"
|
||||
else
|
||||
TOTAL=$(echo "$ALL_REFS" | wc -l)
|
||||
echo "All ${TOTAL} file/folder references are unique." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} duplicate reference(s) found.** Review for cross-section validity." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Duplicate file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Empty language keys check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Empty Language Keys" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$LANG_FILES" ]; then
|
||||
echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL_FILES=0
|
||||
for FILE in $LANG_FILES; do
|
||||
TOTAL_FILES=$((TOTAL_FILES + 1))
|
||||
# Find lines with KEY= but no value (empty or whitespace-only after =)
|
||||
EMPTY_KEYS=$(grep -nP '^[A-Z_]+=\s*$' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$EMPTY_KEYS" ]; then
|
||||
COUNT=$(echo "$EMPTY_KEYS" | wc -l)
|
||||
echo "::warning file=${FILE}::${COUNT} empty language key(s)"
|
||||
echo "- \`${FILE}\`: ${COUNT} empty key(s)" >> $GITHUB_STEP_SUMMARY
|
||||
while IFS= read -r LINE; do
|
||||
LINE_NUM=$(echo "$LINE" | cut -d: -f1)
|
||||
KEY=$(echo "$LINE" | cut -d: -f2 | cut -d= -f1)
|
||||
echo " - Line ${LINE_NUM}: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY
|
||||
done <<< "$EMPTY_KEYS"
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$WARNINGS" -eq 0 ]; then
|
||||
echo "All ${TOTAL_FILES} language file(s) have populated keys." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} empty language key(s) across ${TOTAL_FILES} file(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Empty language keys check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
release-readiness:
|
||||
name: Release Readiness Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
name: "Universal: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.08.00
|
||||
# INGROUP: mokoplatform.Automation
|
||||
# VERSION: 01.02.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
+535
-534
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||
|
||||
name: "Joomla: Metadata Validation"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GITEA_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 }}
|
||||
|
||||
jobs:
|
||||
validate-metadata:
|
||||
name: "Validate Joomla Metadata"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Validate metadata against Joomla manifest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||
--path . \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--api-base "${GITEA_URL}/api/v1" \
|
||||
--ci
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,26 +4,23 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# INGROUP: mokoplatform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
- 'fix/**'
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- 'chore/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -46,11 +43,12 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -58,59 +56,40 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup mokocli tools
|
||||
- name: Setup mokoplatform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
# Use pre-installed /opt/mokoplatform if available (updated by cron every 6h)
|
||||
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/cli/manifest_element.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokoplatform
|
||||
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
rm -rf /tmp/mokoplatform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
|
||||
cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Auto-detect and update platform if not set in manifest
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
case "${{ github.ref_name }}" in
|
||||
rc) STABILITY="release-candidate" ;;
|
||||
alpha) STABILITY="alpha" ;;
|
||||
beta) STABILITY="beta" ;;
|
||||
*) STABILITY="development" ;;
|
||||
esac
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
@@ -178,7 +157,6 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -186,10 +164,9 @@ jobs:
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -226,7 +203,6 @@ jobs:
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
@@ -240,7 +216,6 @@ jobs:
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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/rc-revert.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||
|
||||
name: "RC Revert"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
revert:
|
||||
name: Rename rc/ back to dev/
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == false &&
|
||||
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
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 }}"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-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
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${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
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -7,8 +7,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# INGROUP: mokoplatform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
@@ -33,8 +33,7 @@ on:
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -297,17 +296,19 @@ jobs:
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
# Source directory: source/, src/, or htdocs/ (any is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
if [ -d "source" ]; then
|
||||
SOURCE_DIR="source"
|
||||
elif [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
# Platform/tooling repos don't need source/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
missing_required+=("source/ or src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
@@ -0,0 +1,312 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_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 }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Configure git for bot pushes
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Auto-bump patch version
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Strip any existing suffix before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
else
|
||||
STABILITY="development"
|
||||
fi
|
||||
|
||||
# Version suffix per stability stream
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
*) SUFFIX=""; TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Propagate version with stability suffix to all manifest files
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Re-read version (now includes suffix from version_set_platform)
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# Permission check: admin or maintain role required
|
||||
ACTOR="${{ github.actor }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${{ steps.meta.outputs.display_version }}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,130 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
# PATH: /.mokogitea/workflows/version-set.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||
|
||||
name: "Joomla: Set Version"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g. 01.00.00)"
|
||||
required: true
|
||||
type: string
|
||||
branch:
|
||||
description: "Branch to update (default: current)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
set-version:
|
||||
name: Set Version to ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update manifest version
|
||||
run: |
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||
else
|
||||
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||
fi
|
||||
|
||||
- name: Update README.md version
|
||||
run: |
|
||||
if [ -f "README.md" ]; then
|
||||
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||
echo "README.md version updated to ${VERSION}"
|
||||
else
|
||||
echo "::warning::No VERSION line found in README.md — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
# Check if this version already has an entry
|
||||
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||
else
|
||||
# Insert new version entry after [Unreleased] or at the top after header
|
||||
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
else
|
||||
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
fi
|
||||
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||
fi
|
||||
else
|
||||
echo "::warning::No CHANGELOG.md found — skipping"
|
||||
fi
|
||||
|
||||
- name: Update FILE INFORMATION blocks
|
||||
run: |
|
||||
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||
while IFS= read -r -d '' FILE; do
|
||||
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "Moko Consulting [bot]"
|
||||
git config user.email "hello@mokoconsulting.tech"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes detected — nothing to commit"
|
||||
else
|
||||
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||
|
||||
Authored-by: Moko Consulting"
|
||||
git push
|
||||
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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/workflow-sync-trigger.yml
|
||||
# VERSION: 01.01.00
|
||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
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]')
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
id: platform
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
case "$REPO" in
|
||||
Template-Joomla) PLATFORM="joomla" ;;
|
||||
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||
Template-Go) PLATFORM="go" ;;
|
||||
Template-MCP) PLATFORM="mcp" ;;
|
||||
Template-Generic) PLATFORM="" ;;
|
||||
*) PLATFORM="" ;;
|
||||
esac
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform: ${PLATFORM:-all}"
|
||||
|
||||
- name: Clone mokocli
|
||||
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
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
|
||||
- name: Run workflow sync
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||
ARGS="${ARGS} --phase repos"
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||
fi
|
||||
|
||||
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||
+225
-43
@@ -1,54 +1,236 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.08.00] --- 2026-06-23
|
||||
|
||||
## [01.08.00] --- 2026-06-23
|
||||
<!-- VERSION: 01.02.00 -->
|
||||
|
||||
## [01.07.00] --- 2026-06-23
|
||||
All notable changes to MokoSuiteCross will be documented in this file.
|
||||
|
||||
## [01.07.00] --- 2026-06-23
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
- **Full ACL system**: 12 granular permissions in access.xml with permissions fieldset in config.xml
|
||||
- **ACL enforcement**: All controllers and views check permissions before allowing actions
|
||||
- **MokoSuiteCrossHelper::getActions()**: Centralized ACL helper for toolbar and view logic
|
||||
|
||||
### Fixed
|
||||
- **License warning**: Removed duplicate from system plugin (install script already shows it)
|
||||
|
||||
## [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
|
||||
## [01.02.00] --- 2026-06-21
|
||||
|
||||
### Changed
|
||||
- **Default templates**: Updated to use platform-specific placeholders (social/short/chat/email) with graceful fallback
|
||||
- **Rebrand complete**: All 1,151 language key references renamed from `MOKOJOOMCROSS` to `MOKOSUITECROSS` across .ini, .xml, and .php files
|
||||
- **Event names**: All Joomla events renamed from `onMokoJoomCross*` to `onMokoSuiteCross*`
|
||||
- **Telegram default bot**: Updated from @MokoWaaSBot to @mokosuite_bot with obfuscated embedded token
|
||||
- **Branding**: All `MokoWaaS` references updated to `MokoSuite` across codebase, wiki, and docs
|
||||
- **Wiki**: Reorganized into folder structure (getting-started/, user-guide/, services/, developer/)
|
||||
- **README**: Updated with all 36 implemented service plugins and current feature list
|
||||
- **PR workflow**: Added README/CHANGELOG diff check — blocks PRs that modify source without updating CHANGELOG
|
||||
|
||||
### 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
|
||||
- **SendGrid**: Removed duplicate `curl_setopt_array` with undefined `$token` variable in `publish()`
|
||||
- **Reddit**: Removed duplicate `curl_setopt_array` with undefined `$token` variable in `publish()`
|
||||
- **TikTok**: Removed duplicate `curl_setopt_array` in `publish()`
|
||||
- **Pinterest**: Removed duplicate `curl_setopt_array` in `publish()`
|
||||
- **Telegram**: Added missing `<config>` section to plugin XML for parse_mode and disable_preview settings
|
||||
|
||||
### Fixed (previous)
|
||||
- **C-1 OauthController**: Added CSRF nonce validation to OAuth callback — session-based nonce is generated during `authorize()`, embedded in the state parameter, and verified in `callback()` to prevent CSRF attacks
|
||||
- **C-2 DispatchController**: Added POST method enforcement — rejects non-POST requests with 405 status
|
||||
- **C-5 ServiceModel**: Credential form fields (`cred_*`) are now collected into the `credentials` JSON column on save, and expanded back into individual fields on load — previously these fields were silently discarded
|
||||
- **H-1 Event pattern**: Fixed Joomla 5 SubscriberInterface incompatibility where `onMokoSuiteCrossGetServices` by-reference pattern silently lost all service plugins — dispatchers now read plugin instances from Event ArrayAccess indices after dispatch
|
||||
- **H-4 ServiceTable**: Added `check()` method with alias generation, required field validation (title, service_type), timestamp management, and JSON defaults for credentials/params
|
||||
- **H-9 WebhookService**: Fixed credential key mismatch — `publish()` and `validateCredentials()` now use keys matching the service.xml form fields (`url`, `method`, `auth_type`, `bearer_token`, `basic_username`, `basic_password`, `content_type`) and properly apply Bearer/Basic auth headers
|
||||
- **M-4 ServiceIconHelper**: Escaped `$extraClass` parameter in `renderIcon()` with `htmlspecialchars()` to prevent XSS
|
||||
- **M-5 Content plugin**: Fixed double-escaped HTML in cross-post history panel — uses `setFieldAttribute()` to inject history HTML into the note field description after XML load, avoiding XML attribute encoding
|
||||
|
||||
- **Content plugin**: Fixed `onContentBeforeDisplay` signature for Joomla 5/6 — now accepts `BeforeDisplayEvent` object instead of individual parameters
|
||||
|
||||
- **QueueProcessor**: Replaced read-then-write DB lock with MySQL advisory locks (`GET_LOCK`/`RELEASE_LOCK`) to eliminate race condition
|
||||
- **Twitter/X**: Replaced Bearer token auth with OAuth 1.0a (HMAC-SHA1) — Bearer tokens are app-only and cannot create tweets
|
||||
- **service.xml**: Fixed missing closing `</field>` tag on webhook method field
|
||||
- **Views**: Added missing `Toolbar` and `Route` imports in Logs, Posts, Services, Template, Templates HtmlView files
|
||||
- **13 service plugins**: Fixed broken `publish()` methods that had literal placeholder URLs instead of using credential values — ActivityPub, Blogger, Ghost, Google Business, Hashnode, Matrix, Medium, Nostr, RSS Feed, Threads, Tumblr, WhatsApp, WordPress
|
||||
- **Ghost**: Proper JWT auth from `{id}:{secret}` admin API key format
|
||||
- **WordPress**: Correct Basic Auth (not Bearer) with Application Passwords
|
||||
- **Medium**: 2-step flow — fetch user ID via /v1/me, then post
|
||||
- **Matrix**: PUT with transaction ID for idempotent message sending
|
||||
- **Hashnode**: GraphQL mutation with proper query structure
|
||||
- **Threads**: 2-step container creation + publish flow
|
||||
- **WhatsApp**: Meta Cloud API with messaging_product payload
|
||||
- **Nostr**: Stub with clear "not yet implemented" message (requires WebSocket)
|
||||
- **RSS Feed**: Local service — no external API, always succeeds
|
||||
|
||||
|
||||
### Added
|
||||
- **ServiceIconHelper**: Centralised icon mapping for all 34 service types — replaces per-template icon arrays with `ServiceIconHelper::getIcon()` / `::renderIcon()`
|
||||
- **Service Stats drill-down**: New `servicestats` view with per-service analytics — post counts, success rate, daily trend chart, recent posts table, and top articles list
|
||||
- **Dashboard service links**: Service breakdown table rows now link to the per-service stats view with service type icons
|
||||
- **Posts list icons**: Service type column in the posts list now shows the service icon
|
||||
- **Category routing rules**: New `#__mokosuitecross_category_rules` table to whitelist services per Joomla category — if rules exist for a category, only those services receive posts; no rules = all services (backward compatible)
|
||||
- **CrossPostDispatcher**: Category rule filtering integrated before per-article service filter in the dispatch loop
|
||||
- **Template editor**: Live character counter below template body textarea with platform-aware limits (green/yellow/red badges)
|
||||
- **Template editor**: Added `{tags}`, `{hashtags}`, and `{field:xxx}` rows to the placeholder reference table
|
||||
- **Content plugin**: Cross-post history panel in article editor showing last 10 posts with status badges, service names, timestamps, and error messages
|
||||
- **Config**: New "Category Rules" fieldset with explanatory note about the feature
|
||||
|
||||
- **CrossPostDispatcher**: New static helper (`com_mokosuitecross/Helper/CrossPostDispatcher`) centralising dispatch logic for reuse by all source plugins
|
||||
- **Content plugin**: Added `onContentAfterSave` and `onContentChangeState` handlers with Joomla 5/6 event compatibility, dispatching via `CrossPostDispatcher`
|
||||
- **plg_system_mokosuitecross_events**: New source plugin for MokoSuiteCalendar — cross-posts calendar events when published
|
||||
- **plg_system_mokosuitecross_gallery**: New source plugin for MokoSuiteGallery — cross-posts galleries and images when published
|
||||
|
||||
- **Credential fields**: Added fields for 19 previously missing services (Pinterest, Tumblr, TikTok, Nostr, ActivityPub, Brevo, ConvertKit, Constant Contact, Hashnode, Blogger, Google Business, RSS Feed config)
|
||||
- **Twitter**: Access Token and Access Token Secret fields for OAuth 1.0a
|
||||
- **LinkedIn**: Refresh token field for automatic token renewal
|
||||
- **Bluesky**: PDS URL field for self-hosted instances
|
||||
- **Discord**: Username and avatar URL override fields
|
||||
- **Mailchimp**: From name and from email fields
|
||||
- **SendGrid**: From email and from name fields
|
||||
- **Reddit**: Account password field for script-type OAuth
|
||||
- **WordPress**: Default post status selector (draft/publish)
|
||||
- **Dev.to**: Organization ID field
|
||||
- **Ghost**: Default post status selector (draft/published)
|
||||
- **Webhook**: Auth type selector (none/bearer/basic), auth token field, content type selector (JSON/form)
|
||||
- **RSS Feed**: Feed title and max items config fields
|
||||
- **OAuth services**: Added Pinterest, Tumblr, TikTok, Constant Contact, Blogger, Google Business to OAuth authorize flow
|
||||
- **Developer Guide**: Comprehensive wiki page for building new service plugins
|
||||
- **Help articles**: 42 KB articles on mokoconsulting.tech (overview, installation, 34 per-service guides, templates, queue, troubleshooting)
|
||||
- **Service help link**: Per-service "Setup Guide" button in service edit sidebar links to the matching KB article
|
||||
- **Evergreen re-sharing**: Articles can be marked as evergreen for automatic recurring cross-posts on a configurable interval (default 30 days)
|
||||
- **Post edit form**: Full CRUD for queue posts — edit message, reschedule, change status, re-queue failed posts
|
||||
- **Manual post creator**: New button in Post Queue toolbar to create manual cross-posts with article/service selection, custom message, and optional scheduling
|
||||
- **Scheduled posts**: Calendar picker for scheduling posts to specific date/time; scheduled_at shown in queue list
|
||||
- **Dashboard trend chart**: Chart.js line chart showing daily posted vs failed counts between stat cards and service breakdown
|
||||
- **Dashboard date range filter**: Period selector (7/30/90 days, all time) filters service breakdown, top articles, and trend chart
|
||||
- **Hashtag placeholders**: `{tags}` (comma-separated) and `{hashtags}` (#-prefixed space-separated) template placeholders from article tags
|
||||
- **Posts service filter**: SQL-driven service dropdown filter in posts list, plus search filter by article title or message content
|
||||
- **CSV export**: "Export CSV" toolbar button on posts list to download filtered post data as CSV
|
||||
- **WordPress canonical URL**: WordPress cross-posts now include an "Originally published at" source link appended to content with the Joomla article URL
|
||||
- **REST API dispatch endpoint**: `POST /api/v1/mokosuitecross/dispatch` — trigger cross-posts for an article via API with optional service filtering, duplicate guard, and template rendering
|
||||
|
||||
|
||||
### Added (original)
|
||||
|
||||
#### Core Engine
|
||||
- Cross-posting engine dispatches articles to service plugins on publish
|
||||
- System plugin hooks `onContentAfterSave` and `onContentChangeState`
|
||||
- Duplicate guard prevents re-posting to services that already received an article
|
||||
- Message template rendering with 8 placeholders: `{title}`, `{url}`, `{introtext}`, `{fulltext}`, `{image}`, `{category}`, `{author}`, `{date}`
|
||||
- Custom `mokosuitecross` plugin group for extensible service architecture
|
||||
- `MokoSuiteCrossServiceInterface` contract for all service plugins
|
||||
|
||||
#### Admin Component (5 views)
|
||||
- **Dashboard** — summary cards, posts-by-service analytics with success rates, top cross-posted articles, recent activity feed, PP Pro migration banner, page-load processing warning
|
||||
- **Post Queue** — list with color-coded status badges, error messages, retry counts, platform post IDs, article/service columns, date filters
|
||||
- **Services** — CRUD with service type selector (34 platforms organized by category), default/custom mode badges, publish toggle, credential editor
|
||||
- **Templates** — CRUD for message templates, per-platform assignment, placeholder reference panel, template body preview
|
||||
- **Activity Logs** — list with level badges (info/warning/error), service column, context data, level and search filters
|
||||
|
||||
#### Queue Processing (3 methods)
|
||||
- Joomla Scheduled Task plugin (`plg_task_mokosuitecross`) — preferred, processes 20 posts per run
|
||||
- Page-load fallback via system plugin `onAfterRender` — configurable throttle interval, backend/frontend/both
|
||||
- Shared `QueueProcessor` helper with DB lock to prevent concurrent execution
|
||||
- Failed post retry with configurable max retries and exponential delay
|
||||
- Scheduled post support (`scheduled_at` column)
|
||||
- Automatic log cleanup based on configurable retention period
|
||||
|
||||
#### Per-Article Controls
|
||||
- "Cross-Posting" fieldset injected into article editor via `onContentPrepareForm`
|
||||
- Skip cross-posting toggle per article
|
||||
- Service selection checkboxes (unchecked = post to all enabled services)
|
||||
|
||||
#### OAuth 2.0
|
||||
- `OAuthHelper` with authorization URL generation, code-to-token exchange, token storage
|
||||
- Twitter PKCE flow support
|
||||
- `OauthController` with authorize and callback endpoints
|
||||
- Reads client ID/secret from service plugin params
|
||||
|
||||
#### Perfect Publisher Pro Migration
|
||||
- Reads `#__autotweet_channels` table with per-platform credential mapping
|
||||
- Fallback extraction from component params when channel table missing
|
||||
- Maps Facebook, Twitter, LinkedIn, Telegram, Discord, Slack, Mastodon
|
||||
- Creates services in disabled state for manual verification
|
||||
- One-click migration from dashboard
|
||||
|
||||
#### Service Plugins (34 platforms)
|
||||
|
||||
**Social Media (12)**
|
||||
- Facebook / Meta — Graph API v19.0, default MokoSuite app mode, page feed posting
|
||||
- X / Twitter — API v2, OAuth 2.0 Bearer Token, 280 char limit
|
||||
- LinkedIn — Share API v2, organization + personal profile, 3000 char limit
|
||||
- Mastodon — API v1, multi-instance, hashtags, 500 char limit
|
||||
- Bluesky — AT Protocol, session auth, app passwords, 300 char limit
|
||||
- Threads (Meta) — Threads Publishing API, default app mode, 500 char limit
|
||||
- Pinterest — Pins API v5, board selection, image-focused
|
||||
- Reddit — OAuth2 link submission, subreddit selection
|
||||
- Tumblr — API v2, link/text posts, OAuth 1.0a
|
||||
- TikTok — Content Posting API, photo slideshows
|
||||
- Nostr — NIP-01 event publishing, configurable relays
|
||||
- ActivityPub — generic Fediverse (Pleroma, Akkoma, Misskey, Pixelfed)
|
||||
|
||||
**Chat / Messaging (8)**
|
||||
- Telegram — Bot API, default @mokosuite_bot + custom bot, HTML/Markdown, 4096 chars
|
||||
- Discord — Webhooks, default MokoSuite webhook mode, embeds, 2000 chars
|
||||
- Slack — Incoming Webhooks, default MokoSuite webhook mode, Block Kit
|
||||
- Microsoft Teams — Incoming Webhooks, default mode, Adaptive Cards
|
||||
- Google Chat — Webhook API, card formatting
|
||||
- WhatsApp Business — Meta Cloud API, template + free-form messages
|
||||
- Matrix / Element — Client-Server API, self-hosted homeserver support
|
||||
- Ntfy — Push notifications, priority levels, action buttons
|
||||
|
||||
**Email / Newsletter (5)**
|
||||
- Mailchimp — Campaigns API, audience selection, send/draft modes
|
||||
- SendGrid — Marketing Campaigns API v3, Single Send creation
|
||||
- Brevo (Sendinblue) — API v3, campaign creation
|
||||
- ConvertKit — API v3, broadcast creation
|
||||
- Constant Contact — API v3, campaign creation
|
||||
|
||||
**Publishing / Blogging (6)**
|
||||
- Medium — Publishing API, full HTML, canonical URL, tags
|
||||
- WordPress — REST API v2, Application Passwords, category mapping
|
||||
- Dev.to — Forem API, markdown, series support
|
||||
- Ghost — Admin API v5, JWT auth, full HTML
|
||||
- Hashnode — GraphQL API, cover image, tags
|
||||
- Google Blogger — Blogger API v3, labels from categories
|
||||
|
||||
**Business (1)**
|
||||
- Google Business Profile — API v1, local posts (UPDATE/EVENT/OFFER)
|
||||
|
||||
**Universal (2)**
|
||||
- Generic Webhook — POST/PUT to any URL, JSON/form body, custom headers (IFTTT, Zapier, n8n, Make)
|
||||
- RSS Feed — dedicated cross-post feed generation
|
||||
|
||||
#### Plugin Configuration
|
||||
- Telegram: default bot token, parse mode, link preview toggle
|
||||
- Facebook: default page access token, default page ID
|
||||
- Discord: default webhook URL, embed color
|
||||
- Slack: default webhook URL
|
||||
- LinkedIn: OAuth client ID/secret, redirect URI
|
||||
- Mastodon: default instance URL, visibility, hashtags
|
||||
- Bluesky: default PDS URL, auto link cards
|
||||
- Mailchimp: default sender name/email, auto-send toggle
|
||||
- Microsoft Teams: default webhook URL
|
||||
- Threads: default webhook URL
|
||||
|
||||
#### Infrastructure
|
||||
- 7 CI/CD workflows: CI, auto-release, pre-release, auto-bump, update-server, cascade-dev, issue-branch
|
||||
- Joomla update server (`updates.xml`) with development channel
|
||||
- WebServices REST API plugin with CRUD routes for posts and services
|
||||
- Database: 4 tables (services, posts, templates, logs) with default templates
|
||||
- Package installer with auto-enable for core + task + service plugins
|
||||
- 9 wiki documentation pages
|
||||
- Windows Terminal profile in Joomla dropdown
|
||||
|
||||
|
||||
## [01.01.00] - 2026-06-19
|
||||
|
||||
### Added
|
||||
- Initial package structure with component, system plugin, content plugin, and webservices plugin
|
||||
- Admin component with dashboard, post queue, services management, and activity logs
|
||||
- System plugin triggering cross-post on article publish via `onContentAfterSave`
|
||||
- Content plugin adding cross-post controls to article editor
|
||||
- WebServices API plugin with REST endpoints for posts and services
|
||||
- Custom `mokosuitecross` plugin group for extensible service architecture
|
||||
- Service plugins: Facebook, X/Twitter, LinkedIn, Mastodon, Bluesky, Mailchimp, Telegram, Discord, Slack
|
||||
- Database tables: services, posts, templates, logs
|
||||
- Perfect Publisher Pro migration tool in installer script
|
||||
- Message template system with per-platform placeholders
|
||||
- Post queue with scheduled posting, retry logic, and delivery tracking
|
||||
|
||||
## [01.00] - 2026-05-28
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Makefile for Joomla Extensions
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# MokoSuiteCross — Cross-posting Joomla content to social media, email marketing, and chat platforms
|
||||
|
||||
# ==============================================================================
|
||||
# CONFIGURATION - Customize these for your extension
|
||||
# ==============================================================================
|
||||
|
||||
# Extension Configuration
|
||||
EXTENSION_NAME := mokosuitecross
|
||||
EXTENSION_TYPE := package
|
||||
# Options: module, plugin, component, package, template
|
||||
EXTENSION_VERSION := 1.0.0
|
||||
|
||||
# Module Configuration (for modules only)
|
||||
MODULE_TYPE := site
|
||||
# Options: site, admin
|
||||
|
||||
# Plugin Configuration (for plugins only)
|
||||
PLUGIN_GROUP := system
|
||||
# Options: system, content, user, authentication, etc.
|
||||
|
||||
# Directories
|
||||
SRC_DIR := src
|
||||
BUILD_DIR := build
|
||||
DIST_DIR := dist
|
||||
DOCS_DIR := docs
|
||||
|
||||
# Joomla Installation (for local testing - customize paths)
|
||||
JOOMLA_ROOT := /var/www/html/joomla
|
||||
JOOMLA_VERSION := 4
|
||||
|
||||
# Tools
|
||||
PHP := php
|
||||
COMPOSER := composer
|
||||
NPM := npm
|
||||
PHPCS := vendor/bin/phpcs
|
||||
PHPCBF := vendor/bin/phpcbf
|
||||
PHPUNIT := vendor/bin/phpunit
|
||||
ZIP := zip
|
||||
|
||||
# Coding Standards
|
||||
PHPCS_STANDARD := Joomla
|
||||
|
||||
# Colors for output
|
||||
COLOR_RESET := \033[0m
|
||||
COLOR_GREEN := \033[32m
|
||||
COLOR_YELLOW := \033[33m
|
||||
COLOR_BLUE := \033[34m
|
||||
COLOR_RED := \033[31m
|
||||
|
||||
# ==============================================================================
|
||||
# TARGETS
|
||||
# ==============================================================================
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
.PHONY: install-deps
|
||||
install-deps: ## Install all dependencies (Composer + npm)
|
||||
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
|
||||
@if [ -f "composer.json" ]; then \
|
||||
$(COMPOSER) install; \
|
||||
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run PHP linter (syntax check)
|
||||
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
|
||||
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
|
||||
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
|
||||
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
|
||||
|
||||
.PHONY: phpcs
|
||||
phpcs: ## Run PHP CodeSniffer (Joomla standards)
|
||||
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
|
||||
@if [ -f "$(PHPCS)" ]; then \
|
||||
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: validate
|
||||
validate: lint phpcs ## Run all validation checks
|
||||
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
|
||||
@rm -rf $(BUILD_DIR) $(DIST_DIR)
|
||||
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
|
||||
|
||||
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
|
||||
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
|
||||
|
||||
.PHONY: minify
|
||||
minify: ## Minify CSS/JS assets
|
||||
@echo "Minifying assets..."
|
||||
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
|
||||
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
|
||||
elif [ -f "scripts/minify.js" ]; then \
|
||||
node scripts/minify.js; \
|
||||
else \
|
||||
echo "No minify script found"; \
|
||||
fi
|
||||
|
||||
.PHONY: build
|
||||
build: clean validate minify ## Build extension package
|
||||
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
|
||||
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
|
||||
|
||||
# Determine package prefix based on extension type
|
||||
@case "$(EXTENSION_TYPE)" in \
|
||||
module) \
|
||||
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
plugin) \
|
||||
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
component) \
|
||||
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
package) \
|
||||
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
template) \
|
||||
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
\
|
||||
mkdir -p "$$BUILD_TARGET"; \
|
||||
\
|
||||
echo "Building $$PACKAGE_PREFIX..."; \
|
||||
\
|
||||
rsync -av --progress \
|
||||
--exclude='$(BUILD_DIR)' \
|
||||
--exclude='$(DIST_DIR)' \
|
||||
--exclude='.git*' \
|
||||
--exclude='vendor/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='tests/' \
|
||||
--exclude='Makefile' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='package.json' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='phpunit.xml' \
|
||||
--exclude='*.md' \
|
||||
--exclude='.editorconfig' \
|
||||
. "$$BUILD_TARGET/"; \
|
||||
\
|
||||
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
|
||||
\
|
||||
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
|
||||
|
||||
.PHONY: package
|
||||
package: build ## Alias for build
|
||||
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
|
||||
|
||||
.PHONY: release
|
||||
release: validate build ## Create a release (validate + build)
|
||||
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
|
||||
|
||||
.PHONY: version
|
||||
version: ## Display version information
|
||||
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
|
||||
@echo " Name: $(EXTENSION_NAME)"
|
||||
@echo " Type: $(EXTENSION_TYPE)"
|
||||
@echo " Version: $(EXTENSION_VERSION)"
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: ## Run security checks on dependencies
|
||||
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
|
||||
@if [ -f "composer.json" ]; then \
|
||||
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: all
|
||||
all: install-deps validate build ## Run complete build pipeline
|
||||
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteCross
|
||||
|
||||
<!-- VERSION: 01.08.00 -->
|
||||
<!-- VERSION: 01.02.00 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
|
||||
@@ -14,27 +14,20 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
|
||||
- **Plugin-based services** — Each platform is a separate plugin; install only what you need
|
||||
- **Default bot mode** — Pre-configured bots for Telegram (@mokosuite_bot), Discord, and Slack — just add your channel
|
||||
- **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
|
||||
- **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares
|
||||
- **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token
|
||||
- **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms)
|
||||
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {intro}, {image}, {tags}, {field:xxx})
|
||||
- **Post history** — Track what was posted where, with platform response data
|
||||
- **Evergreen re-sharing** — Automatically re-share articles on a configurable interval
|
||||
- **Category routing** — Route articles to specific services by Joomla category
|
||||
- **Mailchimp templates** — Use saved Mailchimp templates with section injection, or built-in responsive email wrapper
|
||||
- **Migration** — Import settings from Perfect Publisher Pro
|
||||
- **REST API** — WebServices plugin for headless/external integration
|
||||
|
||||
### Supported Platforms (38)
|
||||
### Supported Platforms (36)
|
||||
|
||||
#### Social Media
|
||||
| Platform | Plugin | Status |
|
||||
|----------|--------|--------|
|
||||
| Facebook / Meta | `plg_mokosuitecross_facebook` | Implemented |
|
||||
| X / Twitter | `plg_mokosuitecross_twitter` | Implemented |
|
||||
| Instagram | `plg_mokosuitecross_instagram` | Implemented |
|
||||
| YouTube | `plg_mokosuitecross_youtube` | Implemented |
|
||||
| LinkedIn | `plg_mokosuitecross_linkedin` | Implemented |
|
||||
| Mastodon | `plg_mokosuitecross_mastodon` | Implemented |
|
||||
| Bluesky | `plg_mokosuitecross_bluesky` | Implemented |
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<access component="com_mokosuitecross">
|
||||
<section name="component">
|
||||
<!-- Joomla core actions -->
|
||||
<action name="core.admin" title="JACTION_ADMIN" />
|
||||
<action name="core.options" title="JACTION_OPTIONS" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" />
|
||||
@@ -9,18 +8,7 @@
|
||||
<action name="core.delete" title="JACTION_DELETE" />
|
||||
<action name="core.edit" title="JACTION_EDIT" />
|
||||
<action name="core.edit.state" title="JACTION_EDITSTATE" />
|
||||
<!-- Component-specific actions -->
|
||||
<action name="mokosuitecross.crosspost" title="COM_MOKOSUITECROSS_ACTION_CROSSPOST" />
|
||||
<action name="mokosuitecross.crosspost.manual" title="COM_MOKOSUITECROSS_ACTION_CROSSPOST_MANUAL" />
|
||||
<action name="mokosuitecross.delete.remote" title="COM_MOKOSUITECROSS_ACTION_DELETE_REMOTE" />
|
||||
<action name="mokosuitecross.services.manage" title="COM_MOKOSUITECROSS_ACTION_SERVICES_MANAGE" />
|
||||
<action name="mokosuitecross.services.credentials" title="COM_MOKOSUITECROSS_ACTION_SERVICES_CREDENTIALS" />
|
||||
<action name="mokosuitecross.templates.manage" title="COM_MOKOSUITECROSS_ACTION_TEMPLATES_MANAGE" />
|
||||
<action name="mokosuitecross.logs.view" title="COM_MOKOSUITECROSS_ACTION_LOGS_VIEW" />
|
||||
<action name="mokosuitecross.logs.purge" title="COM_MOKOSUITECROSS_ACTION_LOGS_PURGE" />
|
||||
<action name="mokosuitecross.queue.manage" title="COM_MOKOSUITECROSS_ACTION_QUEUE_MANAGE" />
|
||||
<action name="mokosuitecross.queue.export" title="COM_MOKOSUITECROSS_ACTION_QUEUE_EXPORT" />
|
||||
<action name="mokosuitecross.dispatch" title="COM_MOKOSUITECROSS_ACTION_DISPATCH" />
|
||||
<action name="mokosuitecross.migrate" title="COM_MOKOSUITECROSS_ACTION_MIGRATE" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -24,17 +24,6 @@
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="delete_on_unpublish"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH_DESC"
|
||||
default="0"
|
||||
class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="retry_max"
|
||||
type="number"
|
||||
@@ -75,51 +64,6 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="utm" label="COM_MOKOSUITECROSS_CONFIG_UTM">
|
||||
<field
|
||||
name="utm_enabled"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED_DESC"
|
||||
default="0"
|
||||
class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="utm_source"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE_DESC"
|
||||
default="{platform}"
|
||||
showon="utm_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="utm_medium"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM_DESC"
|
||||
default="social"
|
||||
showon="utm_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="utm_campaign"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN_DESC"
|
||||
default="mokosuitecross"
|
||||
showon="utm_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="utm_content"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT_DESC"
|
||||
hint="Optional"
|
||||
showon="utm_enabled:1"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
|
||||
<field
|
||||
name="evergreen_enabled"
|
||||
@@ -199,19 +143,4 @@
|
||||
description="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
name="permissions"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
description="JCONFIG_PERMISSIONS_DESC">
|
||||
<field
|
||||
name="rules"
|
||||
type="rules"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
component="com_mokosuitecross"
|
||||
filter="rules"
|
||||
validate="rules"
|
||||
section="component"
|
||||
/>
|
||||
</fieldset>
|
||||
</config>
|
||||
|
||||
@@ -5,20 +5,6 @@
|
||||
COM_MOKOSUITECROSS="MokoSuiteCross"
|
||||
COM_MOKOSUITECROSS_DESCRIPTION="Cross-posting Joomla content to social media, email marketing, and chat platforms"
|
||||
|
||||
; ACL Actions
|
||||
COM_MOKOSUITECROSS_ACTION_CROSSPOST="Cross-Post Articles"
|
||||
COM_MOKOSUITECROSS_ACTION_CROSSPOST_MANUAL="Manually Create Posts"
|
||||
COM_MOKOSUITECROSS_ACTION_DELETE_REMOTE="Delete from Remote Platforms"
|
||||
COM_MOKOSUITECROSS_ACTION_SERVICES_MANAGE="Manage Services"
|
||||
COM_MOKOSUITECROSS_ACTION_SERVICES_CREDENTIALS="View Service Credentials"
|
||||
COM_MOKOSUITECROSS_ACTION_TEMPLATES_MANAGE="Manage Templates"
|
||||
COM_MOKOSUITECROSS_ACTION_LOGS_VIEW="View Activity Logs"
|
||||
COM_MOKOSUITECROSS_ACTION_LOGS_PURGE="Purge Activity Logs"
|
||||
COM_MOKOSUITECROSS_ACTION_QUEUE_MANAGE="Manage Post Queue"
|
||||
COM_MOKOSUITECROSS_ACTION_QUEUE_EXPORT="Export Post Queue"
|
||||
COM_MOKOSUITECROSS_ACTION_DISPATCH="Trigger API Dispatch"
|
||||
COM_MOKOSUITECROSS_ACTION_MIGRATE="Run Migration"
|
||||
|
||||
; Submenu
|
||||
COM_MOKOSUITECROSS_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOSUITECROSS_SUBMENU_POSTS="Post Queue"
|
||||
@@ -490,19 +476,6 @@ COM_MOKOSUITECROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog"
|
||||
COM_MOKOSUITECROSS_DASHBOARD_QUEUE_DEPTH_WARNING="There are %d posts waiting in the queue. Please verify that the Joomla Task Scheduler is running and the MokoSuiteCross scheduled task is enabled in System → Scheduled Tasks."
|
||||
|
||||
; First-Publish-Only
|
||||
COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH="Delete from Platforms on Unpublish"
|
||||
COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH_DESC="When an article is unpublished or trashed, automatically delete the cross-posted content from remote platforms (where supported)."
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM="UTM Tracking"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED="Enable UTM Parameters"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED_DESC="Append UTM tracking parameters to article URLs in cross-posted content for Google Analytics tracking."
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE="UTM Source"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE_DESC="Value for utm_source. Use {platform} to auto-insert the service type (e.g. facebook, twitter, telegram)."
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM="UTM Medium"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM_DESC="Value for utm_medium. Default: social."
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN="UTM Campaign"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN_DESC="Value for utm_campaign. Default: mokosuitecross."
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT="UTM Content"
|
||||
COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT_DESC="Optional value for utm_content. Leave empty to omit."
|
||||
COM_MOKOSUITECROSS_CONFIG_FIRST_PUBLISH_ONLY="First Publish Only"
|
||||
COM_MOKOSUITECROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC="When enabled, articles are only cross-posted on their first save as published. Subsequent edits to already-published articles will not trigger new cross-posts."
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokosuitecross</name>
|
||||
<version>01.08.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_posts` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`article_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__content.id',
|
||||
`service_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__mokosuitecross_services.id',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'queued' COMMENT 'queued, posting, posted, failed, scheduled, deleted',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'queued' COMMENT 'queued, posting, posted, failed, scheduled',
|
||||
`message` text NOT NULL COMMENT 'Rendered message sent to platform',
|
||||
`platform_post_id` varchar(255) NOT NULL DEFAULT '' COMMENT 'Post ID returned by platform',
|
||||
`platform_response` text NOT NULL COMMENT 'JSON — full API response from platform',
|
||||
@@ -74,27 +74,25 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_logs` (
|
||||
-- Insert default templates
|
||||
INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_body`, `published`, `ordering`, `created`) VALUES
|
||||
('default', 'Default Template', '{title}\n\n{introtext}\n\n{url}', 1, 1, NOW()),
|
||||
('twitter', 'Twitter/X Default', '{short}\n\n{url}', 1, 2, NOW()),
|
||||
('mastodon', 'Mastodon Default', '{social}\n\n{url}\n\n{hashtags}', 1, 3, NOW()),
|
||||
('mailchimp', 'Mailchimp Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW()),
|
||||
('telegram', 'Telegram Default', '<b>{title}</b>\n\n{chat}\n\n<a href=\"{url}\">Read more</a>', 1, 5, NOW()),
|
||||
('discord', 'Discord Default', '**{title}**\n\n{chat}\n\n{url}', 1, 6, NOW()),
|
||||
('slack', 'Slack Default', '*{title}*\n\n{chat}\n\n{url}', 1, 7, NOW()),
|
||||
('facebook', 'Facebook Default', '{social}\n\n{url}', 1, 8, NOW()),
|
||||
('linkedin', 'LinkedIn Default', '{social}\n\n{url}\n\n{hashtags}', 1, 9, NOW()),
|
||||
('bluesky', 'Bluesky Default', '{short}\n\n{url}', 1, 10, NOW()),
|
||||
('threads', 'Threads Default', '{social}\n\n{url}', 1, 11, NOW()),
|
||||
('teams', 'Teams Default', '**{title}**\n\n{chat}\n\n[Read more]({url})', 1, 12, NOW()),
|
||||
('medium', 'Medium Default', '{title}\n\n{social}\n\n{url}', 1, 13, NOW()),
|
||||
('twitter', 'Twitter/X Default', '{title}\n\n{url}', 1, 2, NOW()),
|
||||
('mastodon', 'Mastodon Default', '{title}\n\n{introtext}\n\n{url}\n\n#Joomla', 1, 3, NOW()),
|
||||
('mailchimp', 'Mailchimp Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW()),
|
||||
('telegram', 'Telegram Default', '<b>{title}</b>\n\n{introtext}\n\n<a href=\"{url}\">Read more</a>', 1, 5, NOW()),
|
||||
('discord', 'Discord Default', '**{title}**\n\n{introtext}\n\n{url}', 1, 6, NOW()),
|
||||
('slack', 'Slack Default', '*{title}*\n\n{introtext}\n\n{url}', 1, 7, NOW()),
|
||||
('facebook', 'Facebook Default', '{title}\n\n{introtext}\n\n{url}', 1, 8, NOW()),
|
||||
('linkedin', 'LinkedIn Default', '{title}\n\n{introtext}\n\n{url}', 1, 9, NOW()),
|
||||
('bluesky', 'Bluesky Default', '{title}\n\n{url}', 1, 10, NOW()),
|
||||
('threads', 'Threads Default', '{title}\n\n{introtext}\n\n{url}', 1, 11, NOW()),
|
||||
('teams', 'Teams Default', '**{title}**\n\n{introtext}\n\n[Read more]({url})', 1, 12, NOW()),
|
||||
('medium', 'Medium Default', '{title}\n\n{introtext}\n\n{url}', 1, 13, NOW()),
|
||||
('wordpress', 'WordPress Default', '{title}\n\n{introtext}\n\n{url}', 1, 14, NOW()),
|
||||
('webhook', 'Webhook Default', '{title}\n\n{introtext}\n\n{url}', 1, 15, NOW()),
|
||||
('sendgrid', 'SendGrid Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, NOW()),
|
||||
('brevo', 'Brevo Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, NOW()),
|
||||
('ntfy', 'Ntfy Default', '{title}: {short}', 1, 18, NOW()),
|
||||
('sendgrid', 'SendGrid Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, NOW()),
|
||||
('brevo', 'Brevo Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, NOW()),
|
||||
('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()),
|
||||
('reddit', 'Reddit Default', '{title}', 1, 19, NOW()),
|
||||
('pinterest', 'Pinterest Default', '{title} - {social}', 1, 20, NOW()),
|
||||
('instagram', 'Instagram Default', '{social}\n\n{hashtags}', 1, 21, NOW()),
|
||||
('youtube', 'YouTube Default', '{social}\n\n{url}', 1, 22, NOW());
|
||||
('pinterest', 'Pinterest Default', '{title} - {introtext}', 1, 20, NOW());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -56,7 +56,7 @@ class DispatchController extends BaseController
|
||||
}
|
||||
|
||||
// ACL check — require core.manage on the component
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('mokosuitecross.dispatch', 'com_mokosuitecross')) {
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
|
||||
|
||||
return;
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
@@ -24,13 +23,4 @@ class DisplayController extends BaseController
|
||||
* @var string
|
||||
*/
|
||||
protected $default_view = 'dashboard';
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
throw new \Joomla\CMS\Access\Exception\NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ class PostsController extends AdminController
|
||||
{
|
||||
$this->checkToken('get');
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitecross.queue.export', 'com_mokosuitecross')) {
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class ServiceController extends FormController
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitecross.services.manage', 'com_mokosuitecross')) {
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class ServiceController extends FormController
|
||||
$app->mimeType = 'application/json';
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
echo new JsonResponse(['error' => $e->getMessage()]);
|
||||
echo new JsonResponse($e);
|
||||
}
|
||||
|
||||
$app->close();
|
||||
|
||||
@@ -21,22 +21,4 @@ class ServicesController extends AdminController
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
public function publish(): void
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('core.edit.state', 'com_mokosuitecross')) {
|
||||
throw new \Joomla\CMS\Access\Exception\NotAllowed(\Joomla\CMS\Language\Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), 403);
|
||||
}
|
||||
|
||||
parent::publish();
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitecross')) {
|
||||
throw new \Joomla\CMS\Access\Exception\NotAllowed(\Joomla\CMS\Language\Text::_('JLIB_APPLICATION_ERROR_DELETE_NOT_PERMITTED'), 403);
|
||||
}
|
||||
|
||||
parent::delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,22 +21,4 @@ class TemplatesController extends AdminController
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
public function publish(): void
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitecross.templates.manage', 'com_mokosuitecross')) {
|
||||
throw new \Joomla\CMS\Access\Exception\NotAllowed(\Joomla\CMS\Language\Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), 403);
|
||||
}
|
||||
|
||||
parent::publish();
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitecross.templates.manage', 'com_mokosuitecross')) {
|
||||
throw new \Joomla\CMS\Access\Exception\NotAllowed(\Joomla\CMS\Language\Text::_('JLIB_APPLICATION_ERROR_DELETE_NOT_PERMITTED'), 403);
|
||||
}
|
||||
|
||||
parent::delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
|
||||
/**
|
||||
@@ -244,21 +243,7 @@ class CrossPostDispatcher
|
||||
$params = json_decode($service->params ?: '{}', true) ?: [];
|
||||
|
||||
if (!empty($articleUrl)) {
|
||||
$params['article_url'] = $articleUrl;
|
||||
}
|
||||
|
||||
// Pass article title for platforms that need it (e.g. Bluesky link cards)
|
||||
$db2 = Factory::getDbo();
|
||||
$postRow = $db2->setQuery(
|
||||
$db2->getQuery(true)->select('article_id')->from('#__mokosuitecross_posts')->where('id = ' . $postId)
|
||||
)->loadObject();
|
||||
if ($postRow && $postRow->article_id) {
|
||||
$articleTitle = $db2->setQuery(
|
||||
$db2->getQuery(true)->select('title')->from('#__content')->where('id = ' . (int) $postRow->article_id)
|
||||
)->loadResult();
|
||||
if ($articleTitle) {
|
||||
$params['article_title'] = $articleTitle;
|
||||
}
|
||||
$params['_article_url'] = $articleUrl;
|
||||
}
|
||||
|
||||
// Lifecycle event: before post
|
||||
@@ -398,33 +383,11 @@ class CrossPostDispatcher
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Resolve share image from article attribs
|
||||
$attribs = json_decode($article->attribs ?? '{}', true) ?: [];
|
||||
$imageMode = $attribs['mokosuitecross_share_image'] ?? 'intro';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
switch ($imageMode) {
|
||||
case 'fulltext':
|
||||
if (!empty($images->image_fulltext)) {
|
||||
$introImage = Uri::root() . ltrim($images->image_fulltext, '/');
|
||||
}
|
||||
break;
|
||||
case 'custom':
|
||||
$customImg = $attribs['mokosuitecross_custom_image'] ?? '';
|
||||
if (!empty($customImg)) {
|
||||
$introImage = Uri::root() . ltrim($customImg, '/');
|
||||
}
|
||||
break;
|
||||
case 'none':
|
||||
$introImage = '';
|
||||
break;
|
||||
case 'intro':
|
||||
default:
|
||||
if (!empty($images->image_intro)) {
|
||||
$introImage = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
break;
|
||||
if (!empty($images->image_intro)) {
|
||||
$introImage = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
|
||||
$tagNames = [];
|
||||
@@ -447,54 +410,17 @@ class CrossPostDispatcher
|
||||
return '#' . preg_replace('/\s+/', '', $tag);
|
||||
}, $tagNames));
|
||||
|
||||
// Per-article share text (from article editor Share Content panel)
|
||||
$socialText = $attribs['mokosuitecross_social_text'] ?? '';
|
||||
$shortText = $attribs['mokosuitecross_short_text'] ?? '';
|
||||
$chatText = $attribs['mokosuitecross_chat_text'] ?? '';
|
||||
$emailSubject = $attribs['mokosuitecross_email_subject'] ?? '';
|
||||
$emailBody = $attribs['mokosuitecross_email_body'] ?? '';
|
||||
|
||||
$introStripped = strip_tags(mb_substr($article->introtext ?? '', 0, 280));
|
||||
$titleText = $article->title ?? '';
|
||||
|
||||
// UTM auto-tagging (#154)
|
||||
$componentParams = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$urlRaw = $url;
|
||||
|
||||
if ($componentParams->get('utm_enabled', 0)) {
|
||||
$utmParams = [
|
||||
'utm_source' => $componentParams->get('utm_source', '{platform}'),
|
||||
'utm_medium' => $componentParams->get('utm_medium', 'social'),
|
||||
'utm_campaign' => $componentParams->get('utm_campaign', 'mokosuitecross'),
|
||||
];
|
||||
$utmContent = $componentParams->get('utm_content', '');
|
||||
|
||||
if (!empty($utmContent)) {
|
||||
$utmParams['utm_content'] = $utmContent;
|
||||
}
|
||||
|
||||
$separator = (strpos($url, '?') !== false) ? '&' : '?';
|
||||
$url = $url . $separator . http_build_query($utmParams);
|
||||
}
|
||||
|
||||
return [
|
||||
'{title}' => $titleText,
|
||||
'{introtext}' => $introStripped,
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{url_raw}' => $urlRaw,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
|
||||
'{tags}' => $tagsComma,
|
||||
'{hashtags}' => $hashtags,
|
||||
// Platform-specific share content (falls back to introtext/title if empty)
|
||||
'{social}' => !empty($socialText) ? $socialText : $introStripped,
|
||||
'{short}' => !empty($shortText) ? $shortText : mb_substr($titleText, 0, 250),
|
||||
'{chat}' => !empty($chatText) ? $chatText : $introStripped,
|
||||
'{email_subject}' => !empty($emailSubject) ? $emailSubject : $titleText,
|
||||
'{email_body}' => !empty($emailBody) ? $emailBody : ($article->fulltext ?? $article->introtext ?? ''),
|
||||
'{title}' => $article->title ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
|
||||
'{tags}' => $tagsComma,
|
||||
'{hashtags}' => $hashtags,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -533,15 +459,6 @@ class CrossPostDispatcher
|
||||
|
||||
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Resolve {platform} token in UTM params (replaced with service_type)
|
||||
$message = str_replace('{platform}', $service->service_type, $message);
|
||||
|
||||
// Resolve caption rotation: {random:option1|option2|option3} (#155)
|
||||
$message = preg_replace_callback('/\{random:([^}]+)\}/', function ($matches) {
|
||||
$options = explode('|', $matches[1]);
|
||||
return $options[array_rand($options)];
|
||||
}, $message);
|
||||
|
||||
// Resolve custom field placeholders: {field:field_name}
|
||||
$message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) {
|
||||
$fieldName = $matches[1];
|
||||
@@ -561,82 +478,6 @@ class CrossPostDispatcher
|
||||
/**
|
||||
* Write an entry to the activity log.
|
||||
*/
|
||||
/**
|
||||
* Delete cross-posted content from remote platforms for a given article.
|
||||
*
|
||||
* Finds all posts with status 'posted' for this article, resolves the
|
||||
* service plugin, and calls deletePost() if the plugin supports it.
|
||||
*
|
||||
* @param int $articleId The Joomla article ID
|
||||
*/
|
||||
public static function deleteFromPlatforms(int $articleId): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Find all successfully posted entries for this article
|
||||
$query = $db->getQuery(true)
|
||||
->select('p.*, s.service_type, s.credentials')
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's')
|
||||
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
|
||||
->where($db->quoteName('p.article_id') . ' = ' . $articleId)
|
||||
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
|
||||
->where($db->quoteName('p.platform_post_id') . ' != ' . $db->quote(''));
|
||||
|
||||
$db->setQuery($query);
|
||||
$posts = $db->loadObjectList();
|
||||
|
||||
if (empty($posts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load service plugins
|
||||
PluginHelper::importPlugin('mokosuitecross');
|
||||
$plugins = [];
|
||||
Factory::getApplication()->triggerEvent('onMokoSuiteCrossGetServices', [&$plugins]);
|
||||
|
||||
$pluginMap = [];
|
||||
foreach ($plugins as $plugin) {
|
||||
$pluginMap[$plugin->getServiceType()] = $plugin;
|
||||
}
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$plugin = $pluginMap[$post->service_type] ?? null;
|
||||
|
||||
if (!$plugin instanceof MokoSuiteCrossDeleteInterface) {
|
||||
self::log($db, $post->id, $post->service_id, 'info',
|
||||
'Delete not supported for ' . $post->service_type);
|
||||
continue;
|
||||
}
|
||||
|
||||
$credentials = json_decode($post->credentials, true) ?: [];
|
||||
|
||||
try {
|
||||
$result = $plugin->deletePost($post->platform_post_id, $credentials);
|
||||
|
||||
if (!empty($result['success'])) {
|
||||
// Mark as deleted
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitecross_posts'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('deleted'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $post->id)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
self::log($db, $post->id, $post->service_id, 'info',
|
||||
'Deleted from ' . $post->service_type . ': ' . ($result['message'] ?? 'OK'));
|
||||
} else {
|
||||
self::log($db, $post->id, $post->service_id, 'warning',
|
||||
'Delete failed on ' . $post->service_type . ': ' . ($result['message'] ?? 'Unknown error'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
self::log($db, $post->id, $post->service_id, 'error',
|
||||
'Delete exception on ' . $post->service_type . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void
|
||||
{
|
||||
$log = (object) [
|
||||
|
||||
@@ -71,26 +71,4 @@ class MokoSuiteCrossHelper
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of ACL actions for the component.
|
||||
*
|
||||
* @return \Joomla\CMS\Object\CMSObject
|
||||
*/
|
||||
public static function getActions(): \Joomla\CMS\Object\CMSObject
|
||||
{
|
||||
$user = \Joomla\CMS\Factory::getApplication()->getIdentity();
|
||||
$result = new \Joomla\CMS\Object\CMSObject();
|
||||
|
||||
$actions = \Joomla\CMS\Access\Access::getActionsFromFile(
|
||||
JPATH_ADMINISTRATOR . '/components/com_mokosuitecross/access.xml',
|
||||
'/access/section[@name="component"]/'
|
||||
);
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$result->set($action->name, $user->authorise($action->name, 'com_mokosuitecross'));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
/**
|
||||
* Optional interface for service plugins that support deleting posts
|
||||
* from the remote platform.
|
||||
*
|
||||
* Plugins that implement this can be invoked when a Joomla article
|
||||
* is unpublished or trashed, or when a user manually requests deletion
|
||||
* from the Post Queue view.
|
||||
*/
|
||||
interface MokoSuiteCrossDeleteInterface
|
||||
{
|
||||
/**
|
||||
* Delete a previously published post from the remote platform.
|
||||
*
|
||||
* @param string $platformPostId The platform-specific post ID
|
||||
* @param array $credentials Decrypted credentials for this service
|
||||
*
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
*/
|
||||
public function deletePost(string $platformPostId, array $credentials): array;
|
||||
}
|
||||
@@ -65,11 +65,7 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross — Dashboard', 'share-alt');
|
||||
if ($canDo->get('core.admin')) {
|
||||
ToolbarHelper::preferences('com_mokosuitecross');
|
||||
}
|
||||
ToolbarHelper::preferences('com_mokosuitecross');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross — Activity Logs', 'share-alt');
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'logs.delete', 'JTOOLBAR_DELETE');
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
@@ -38,17 +37,14 @@ class HtmlView extends BaseHtmlView
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$isNew = empty($this->item->id);
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title(
|
||||
'MokoSuiteCross — ' . ($isNew ? Text::_('COM_MOKOSUITECROSS_NEW_POST') : Text::_('COM_MOKOSUITECROSS_EDIT_POST')),
|
||||
'share-alt'
|
||||
);
|
||||
|
||||
if ($canDo->get('mokosuitecross.queue.manage')) {
|
||||
ToolbarHelper::apply('post.apply');
|
||||
ToolbarHelper::save('post.save');
|
||||
}
|
||||
ToolbarHelper::apply('post.apply');
|
||||
ToolbarHelper::save('post.save');
|
||||
|
||||
$toolbar = Toolbar::getInstance('toolbar');
|
||||
$toolbar->appendButton(
|
||||
|
||||
@@ -43,8 +43,6 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross — Post Queue', 'share-alt');
|
||||
ToolbarHelper::addNew('post.add');
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
@@ -39,17 +38,14 @@ class HtmlView extends BaseHtmlView
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$isNew = empty($this->item->id);
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title(
|
||||
'MokoSuiteCross — ' . ($isNew ? Text::_('COM_MOKOSUITECROSS_NEW_SERVICE') : Text::_('COM_MOKOSUITECROSS_EDIT_SERVICE')),
|
||||
'share-alt'
|
||||
);
|
||||
|
||||
if ($canDo->get('mokosuitecross.services.manage')) {
|
||||
ToolbarHelper::apply('service.apply');
|
||||
ToolbarHelper::save('service.save');
|
||||
}
|
||||
ToolbarHelper::apply('service.apply');
|
||||
ToolbarHelper::save('service.save');
|
||||
|
||||
// Dashboard button in toolbar
|
||||
$toolbar = Toolbar::getInstance('toolbar');
|
||||
|
||||
@@ -41,8 +41,6 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross — Services', 'share-alt');
|
||||
ToolbarHelper::addNew('service.add');
|
||||
ToolbarHelper::editList('service.edit');
|
||||
|
||||
@@ -17,7 +17,6 @@ use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
@@ -37,17 +36,13 @@ class HtmlView extends BaseHtmlView
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$isNew = empty($this->item->id);
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title(
|
||||
'MokoSuiteCross — ' . ($isNew ? 'New Template' : 'Edit Template'),
|
||||
'share-alt'
|
||||
);
|
||||
|
||||
if ($canDo->get('mokosuitecross.templates.manage')) {
|
||||
ToolbarHelper::apply('template.apply');
|
||||
ToolbarHelper::save('template.save');
|
||||
}
|
||||
ToolbarHelper::apply('template.apply');
|
||||
ToolbarHelper::save('template.save');
|
||||
ToolbarHelper::cancel('template.cancel');
|
||||
|
||||
// Dashboard link in toolbar
|
||||
|
||||
@@ -41,8 +41,6 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross — Message Templates', 'share-alt');
|
||||
ToolbarHelper::addNew('template.add');
|
||||
ToolbarHelper::editList('template.edit');
|
||||
|
||||
-20
@@ -11,23 +11,3 @@ PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_DESC="Automatically re-share this article o
|
||||
PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_INTERVAL="Re-share Interval (days)"
|
||||
PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_INTERVAL_DESC="How many days to wait between automatic re-shares. Default: 30 days."
|
||||
PLG_CONTENT_MOKOSUITECROSS_HISTORY="Cross-Post History"
|
||||
|
||||
PLG_CONTENT_MOKOSUITECROSS_FIELDSET_SHARE="Share Content"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT="Social Media Text"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT_DESC="Custom text for Facebook, LinkedIn, Threads. Use {social} placeholder in templates. Falls back to intro text if empty."
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT="Short Text (Twitter/Bluesky)"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT_DESC="Optimized text for character-limited platforms (Twitter 280, Bluesky 300). Use {short} placeholder. Falls back to truncated title."
|
||||
PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT="Chat Text"
|
||||
PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT_DESC="Custom text for Telegram, Discord, Slack, Teams. Use {chat} placeholder. Falls back to intro text."
|
||||
PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT="Email Subject"
|
||||
PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT_DESC="Subject line for Mailchimp, SendGrid, Brevo campaigns. Use {email_subject} placeholder. Falls back to article title."
|
||||
PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY="Email Body"
|
||||
PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY_DESC="HTML content for email campaigns. Use {email_body} placeholder. Falls back to full article text."
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE="Share Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_DESC="Which image to use when cross-posting this article."
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_INTRO="Intro Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_FULLTEXT="Full Text Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_CUSTOM="Custom Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_NONE="No Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE="Custom Share Image"
|
||||
PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE_DESC="Select an image from the media manager to use for cross-posting."
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -140,71 +140,6 @@ class MokoSuiteCrossContent extends CMSPlugin implements SubscriberInterface
|
||||
showon="mokosuitecross_skip:0[AND]mokosuitecross_evergreen:1"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokosuitecross_share" label="PLG_CONTENT_MOKOSUITECROSS_FIELDSET_SHARE">
|
||||
<field
|
||||
name="mokosuitecross_social_text"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT_DESC"
|
||||
rows="3"
|
||||
hint="Optimized for Facebook, LinkedIn, Threads. Leave empty to use intro text."
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="mokosuitecross_short_text"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT_DESC"
|
||||
rows="2"
|
||||
hint="For Twitter (280), Bluesky (300). Leave empty for auto-truncated title."
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="mokosuitecross_chat_text"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT_DESC"
|
||||
rows="3"
|
||||
hint="For Telegram, Discord, Slack, Teams. Leave empty to use intro text."
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="mokosuitecross_email_subject"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT_DESC"
|
||||
hint="For Mailchimp, SendGrid, Brevo. Leave empty to use article title."
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="mokosuitecross_email_body"
|
||||
type="editor"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY_DESC"
|
||||
filter="safehtml"
|
||||
buttons="true"
|
||||
hide="readmore,pagebreak"
|
||||
height="200"
|
||||
/>
|
||||
<field
|
||||
name="mokosuitecross_share_image"
|
||||
type="list"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_DESC"
|
||||
default="intro">
|
||||
<option value="intro">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_INTRO</option>
|
||||
<option value="fulltext">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_FULLTEXT</option>
|
||||
<option value="custom">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_CUSTOM</option>
|
||||
<option value="none">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_NONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="mokosuitecross_custom_image"
|
||||
type="media"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE"
|
||||
description="PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE_DESC"
|
||||
showon="mokosuitecross_share_image:custom"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
XML;
|
||||
@@ -390,28 +325,12 @@ XML;
|
||||
$value = func_get_arg(2);
|
||||
}
|
||||
|
||||
if ($context !== 'com_content.article') {
|
||||
if ($context !== 'com_content.article' || $value !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
|
||||
// Unpublish/trash: delete from platforms if configured
|
||||
if ($value === 0 || $value === -2) {
|
||||
if ($params->get('delete_on_unpublish', 0)) {
|
||||
foreach ($pks as $pk) {
|
||||
CrossPostDispatcher::deleteFromPlatforms((int) $pk);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish: auto-post if configured
|
||||
if ($value !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$params->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_BLUESKY_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Bluesky</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="bluesky">bluesky.php</filename>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Bluesky\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -30,7 +29,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "pds_url": "https://bsky.social" // Optional, defaults to bsky.social
|
||||
* }
|
||||
*/
|
||||
class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -66,95 +65,49 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Authentication failed']];
|
||||
}
|
||||
|
||||
// Build external link card embed if URL is in params
|
||||
$embed = null;
|
||||
$articleUrl = $params['article_url'] ?? '';
|
||||
if (!empty($articleUrl)) {
|
||||
$embed = [
|
||||
'$type' => 'app.bsky.embed.external',
|
||||
'external' => [
|
||||
'uri' => $articleUrl,
|
||||
'title' => $params['article_title'] ?? '',
|
||||
'description' => mb_substr(strip_tags($message), 0, 200),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Auto-thread: split long messages at sentence boundaries
|
||||
$chunks = $this->splitIntoThread($message, 300);
|
||||
|
||||
if (count($chunks) === 1) {
|
||||
// Single post
|
||||
return $this->createPost($pds, $authData, $chunks[0], $embed);
|
||||
}
|
||||
|
||||
// Thread: post each chunk as a reply to the previous
|
||||
$rootUri = null;
|
||||
$rootCid = null;
|
||||
$parentUri = null;
|
||||
$parentCid = null;
|
||||
$lastResult = [];
|
||||
|
||||
foreach ($chunks as $i => $chunk) {
|
||||
$record = [
|
||||
// Create post
|
||||
$postData = json_encode([
|
||||
'repo' => $authData['did'],
|
||||
'collection' => 'app.bsky.feed.post',
|
||||
'record' => [
|
||||
'$type' => 'app.bsky.feed.post',
|
||||
'text' => $chunk,
|
||||
'text' => mb_substr($message, 0, 300),
|
||||
'createdAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
];
|
||||
],
|
||||
]);
|
||||
|
||||
// Add reply reference for thread posts after the first
|
||||
if ($rootUri !== null) {
|
||||
$record['reply'] = [
|
||||
'root' => ['uri' => $rootUri, 'cid' => $rootCid],
|
||||
'parent' => ['uri' => $parentUri, 'cid' => $parentCid],
|
||||
];
|
||||
}
|
||||
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
]);
|
||||
|
||||
// Attach link card embed to last post only
|
||||
if ($embed !== null && $i === count($chunks) - 1) {
|
||||
$record['embed'] = $embed;
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
|
||||
$postData = json_encode([
|
||||
'repo' => $authData['did'],
|
||||
'collection' => 'app.bsky.feed.post',
|
||||
'record' => $record,
|
||||
]);
|
||||
if ($response === false) {
|
||||
|
||||
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['success' => false, 'platform_post_id' => $rootUri ?? '', 'response' => ['error' => 'Thread error at post ' . ($i + 1) . ': ' . $curlError]];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode !== 200 || empty($data['uri'])) {
|
||||
return ['success' => false, 'platform_post_id' => $rootUri ?? '', 'response' => $data];
|
||||
}
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
|
||||
if ($rootUri === null) {
|
||||
$rootUri = $data['uri'];
|
||||
$rootCid = $data['cid'];
|
||||
}
|
||||
$parentUri = $data['uri'];
|
||||
$parentCid = $data['cid'];
|
||||
$lastResult = $data;
|
||||
}
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode === 200 && !empty($data['uri'])) {
|
||||
return ['success' => true, 'platform_post_id' => $data['uri'], 'response' => $data];
|
||||
}
|
||||
|
||||
return ['success' => true, 'platform_post_id' => $rootUri, 'response' => array_merge($lastResult, ['thread_count' => count($chunks)])];
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
@@ -174,7 +127,7 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
|
||||
|
||||
private function authenticateWithCache(string $pds, string $handle, string $appPwd): array
|
||||
{
|
||||
$cacheKey = hash('sha256', $pds . $handle);
|
||||
$cacheKey = md5($pds . $handle);
|
||||
|
||||
if (isset(self::$sessionCache[$cacheKey])) {
|
||||
$cached = self::$sessionCache[$cacheKey];
|
||||
@@ -222,157 +175,6 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single Bluesky post (used for non-threaded messages).
|
||||
*/
|
||||
private function createPost(string $pds, array $authData, string $text, ?array $embed = null): array
|
||||
{
|
||||
$record = [
|
||||
'$type' => 'app.bsky.feed.post',
|
||||
'text' => $text,
|
||||
'createdAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
];
|
||||
|
||||
if ($embed !== null) {
|
||||
$record['embed'] = $embed;
|
||||
}
|
||||
|
||||
$postData = json_encode([
|
||||
'repo' => $authData['did'],
|
||||
'collection' => 'app.bsky.feed.post',
|
||||
'record' => $record,
|
||||
]);
|
||||
|
||||
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode === 200 && !empty($data['uri'])) {
|
||||
return ['success' => true, 'platform_post_id' => $data['uri'], 'response' => $data];
|
||||
}
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into thread-sized chunks at sentence boundaries.
|
||||
*/
|
||||
private function splitIntoThread(string $message, int $maxLength): array
|
||||
{
|
||||
if (mb_strlen($message) <= $maxLength) {
|
||||
return [$message];
|
||||
}
|
||||
|
||||
$chunks = [];
|
||||
$remaining = $message;
|
||||
|
||||
while (mb_strlen($remaining) > $maxLength) {
|
||||
$segment = mb_substr($remaining, 0, $maxLength);
|
||||
|
||||
// Try to break at last sentence boundary (. ! ? followed by space)
|
||||
$breakPos = max(
|
||||
mb_strrpos($segment, '. ') ?: 0,
|
||||
mb_strrpos($segment, '! ') ?: 0,
|
||||
mb_strrpos($segment, '? ') ?: 0
|
||||
);
|
||||
|
||||
if ($breakPos < $maxLength * 0.3) {
|
||||
// No good sentence break; try last space
|
||||
$breakPos = mb_strrpos($segment, ' ') ?: $maxLength;
|
||||
} else {
|
||||
$breakPos += 1; // Include the punctuation
|
||||
}
|
||||
|
||||
$chunks[] = trim(mb_substr($remaining, 0, $breakPos));
|
||||
$remaining = trim(mb_substr($remaining, $breakPos));
|
||||
}
|
||||
|
||||
if (!empty($remaining)) {
|
||||
$chunks[] = $remaining;
|
||||
}
|
||||
|
||||
return $chunks;
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$pds = rtrim($credentials['pds_url'] ?? 'https://bsky.social', '/');
|
||||
$handle = $credentials['handle'] ?? '';
|
||||
$appPwd = $credentials['app_password'] ?? '';
|
||||
|
||||
if (empty($handle) || empty($appPwd)) {
|
||||
return ['success' => false, 'message' => 'Missing credentials.'];
|
||||
}
|
||||
|
||||
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||
$parts = explode('/', $platformPostId);
|
||||
$rkey = end($parts);
|
||||
|
||||
if (empty($rkey)) {
|
||||
return ['success' => false, 'message' => 'Invalid AT URI -- could not extract rkey.'];
|
||||
}
|
||||
|
||||
// Authenticate (uses cached session if still valid)
|
||||
$authData = $this->authenticateWithCache($pds, $handle, $appPwd);
|
||||
|
||||
if (empty($authData['accessJwt'])) {
|
||||
return ['success' => false, 'message' => 'Authentication failed.'];
|
||||
}
|
||||
|
||||
$postData = json_encode([
|
||||
'repo' => $authData['did'],
|
||||
'collection' => 'app.bsky.feed.post',
|
||||
'rkey' => $rkey,
|
||||
]);
|
||||
|
||||
$ch = curl_init($pds . '/xrpc/com.atproto.repo.deleteRecord');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
return ['success' => true, 'message' => 'Post deleted successfully.'];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed with HTTP ' . $httpCode];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -70,6 +70,15 @@ class BrevoService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api.brevo.com/v3/emailCampaigns',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+9
@@ -73,6 +73,15 @@ class ConstantcontactService extends CMSPlugin implements SubscriberInterface, M
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api.cc.email/v3/emails',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -66,6 +66,15 @@ class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoSu
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api.convertkit.com/v3/broadcasts',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_DISCORD_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Discord</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="discord">discord.php</filename>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Discord\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -31,7 +30,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "webhook_url": "https://discord.com/api/webhooks/..." // Only for custom mode
|
||||
* }
|
||||
*/
|
||||
class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -127,44 +126,6 @@ class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuite
|
||||
return $this->params->get('default_webhook_url', '');
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$webhookUrl = $this->resolveWebhook($credentials);
|
||||
|
||||
if (empty($webhookUrl)) {
|
||||
return ['success' => false, 'message' => 'Missing webhook URL.'];
|
||||
}
|
||||
|
||||
$apiUrl = $webhookUrl . '/messages/' . $platformPostId;
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 204) {
|
||||
return ['success' => true, 'message' => 'Message deleted successfully.'];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_FACEBOOK_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Facebook</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="facebook">facebook.php</filename>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Facebook\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -32,7 +31,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "page_id": "..." // Required — Facebook Page ID
|
||||
* }
|
||||
*/
|
||||
class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -162,44 +161,6 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit
|
||||
return $this->params->get('default_page_access_token', '');
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$token = $this->resolveToken($credentials);
|
||||
|
||||
if (empty($token)) {
|
||||
return ['success' => false, 'message' => 'Missing access token.'];
|
||||
}
|
||||
|
||||
$apiUrl = 'https://graph.facebook.com/v19.0/' . $platformPostId . '?access_token=' . $token;
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode === 200 && !empty($data['success'])) {
|
||||
return ['success' => true, 'message' => 'Post deleted successfully.'];
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $data['error']['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package 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;
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Instagram</name>
|
||||
<version>01.08.00</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Instagram</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="instagram">instagram.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.sys.ini</language>
|
||||
</languages>
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS">
|
||||
<field
|
||||
name="default_webhook_url"
|
||||
type="url"
|
||||
label="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK"
|
||||
description="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC"
|
||||
size="60"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS="Default Settings"
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK="Default Webhook URL"
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC="Pre-configured MokoSuite webhook URL. Services using default mode will use this URL."
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
|
||||
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage plg_mokosuitecross_instagram
|
||||
* @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\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\MokoSuiteCross\Instagram\Extension\InstagramService;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new InstagramService(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('mokosuitecross', 'instagram')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage plg_mokosuitecross_instagram
|
||||
* @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\Plugin\MokoSuiteCross\Instagram\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* Instagram service plugin for MokoSuiteCross.
|
||||
*
|
||||
* Uses the Meta Content Publishing API — a 2-step flow:
|
||||
* 1. Create a media container via POST /{ig_user_id}/media
|
||||
* 2. Publish the container via POST /{ig_user_id}/media_publish
|
||||
*/
|
||||
class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
|
||||
}
|
||||
|
||||
public function onMokoSuiteCrossGetServices(&$services): void
|
||||
{
|
||||
$services[] = $this;
|
||||
}
|
||||
|
||||
public function getServiceType(): string { return 'instagram'; }
|
||||
public function getServiceName(): string { return 'Instagram'; }
|
||||
public function getMaxLength(): int { return 2200; }
|
||||
public function supportsMedia(): bool { return true; }
|
||||
|
||||
public function publish(string $message, array $media, array $credentials, array $params): array
|
||||
{
|
||||
$token = $this->resolveCredential($credentials, 'access_token');
|
||||
$accountId = $credentials['instagram_account_id'] ?? '';
|
||||
|
||||
if (empty($token) || empty($accountId)) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or Instagram account ID.']];
|
||||
}
|
||||
|
||||
// Step 1: Create media container
|
||||
$containerUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media';
|
||||
$containerData = [
|
||||
'caption' => mb_substr($message, 0, 2200),
|
||||
'access_token' => $token,
|
||||
];
|
||||
|
||||
// Attach image if provided
|
||||
if (!empty($media[0])) {
|
||||
$containerData['image_url'] = $media[0];
|
||||
} else {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Instagram requires at least one image or video.']];
|
||||
}
|
||||
|
||||
$ch = curl_init($containerUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($containerData),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
|
||||
}
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
$containerId = $data['id'];
|
||||
|
||||
// Step 2: Publish the container
|
||||
$publishUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish';
|
||||
$publishData = [
|
||||
'creation_id' => $containerId,
|
||||
'access_token' => $token,
|
||||
];
|
||||
|
||||
$ch = curl_init($publishUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($publishData),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
|
||||
}
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) {
|
||||
return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data];
|
||||
}
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
{
|
||||
$token = $this->resolveCredential($credentials, 'access_token');
|
||||
$accountId = $credentials['instagram_account_id'] ?? '';
|
||||
|
||||
if (empty($token) || empty($accountId)) {
|
||||
return ['valid' => false, 'message' => 'Access token and Instagram account ID are required.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
$ch = curl_init('https://graph.facebook.com/v19.0/me?fields=id,username&access_token=' . urlencode($token));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
|
||||
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
$name = $data['username'] ?? $data['id'];
|
||||
return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $name];
|
||||
}
|
||||
|
||||
return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
private function resolveCredential(array $credentials, string $key): string
|
||||
{
|
||||
$mode = $credentials['mode'] ?? 'default';
|
||||
|
||||
if ($mode === 'custom') {
|
||||
return $credentials[$key] ?? '';
|
||||
}
|
||||
|
||||
return $this->params->get('default_' . $key, '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_LINKEDIN_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Linkedin</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="linkedin">linkedin.php</filename>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Linkedin\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -30,7 +29,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "person_id": "..." // LinkedIn Person URN (fallback)
|
||||
* }
|
||||
*/
|
||||
class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -148,46 +147,6 @@ class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuit
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$token = $credentials['access_token'] ?? '';
|
||||
|
||||
if (empty($token)) {
|
||||
return ['success' => false, 'message' => 'Missing access token.'];
|
||||
}
|
||||
|
||||
$encodedId = urlencode($platformPostId);
|
||||
$apiUrl = 'https://api.linkedin.com/v2/ugcPosts/' . $encodedId;
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 204) {
|
||||
return ['success' => true, 'message' => 'Post deleted successfully.'];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
|
||||
-5
@@ -7,8 +7,3 @@ PLG_MOKOSUITECROSS_MAILCHIMP_DEFAULT_FROM_EMAIL="Default From Email"
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_DEFAULT_FROM_EMAIL_DESC="Default sender email address for Mailchimp campaigns."
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_AUTO_SEND="Auto Send"
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_AUTO_SEND_DESC="Automatically send the campaign on creation instead of saving as draft."
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_FIELDSET_TEMPLATE="Email Template"
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID="Mailchimp Template ID"
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID_DESC="Numeric ID of a saved Mailchimp template. Article content is injected into the template section. Leave empty to use the built-in responsive wrapper."
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION="Template Section Name"
|
||||
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION_DESC="The editable section name in your Mailchimp template where article content is injected. Default: body_content."
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_MAILCHIMP_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Mailchimp</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mailchimp">mailchimp.php</filename>
|
||||
@@ -51,24 +51,6 @@
|
||||
<option value="1">JYES</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="template" label="PLG_MOKOSUITECROSS_MAILCHIMP_FIELDSET_TEMPLATE">
|
||||
<field
|
||||
name="template_id"
|
||||
type="text"
|
||||
label="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID"
|
||||
description="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID_DESC"
|
||||
hint="Leave empty for built-in responsive template"
|
||||
filter="integer"
|
||||
/>
|
||||
<field
|
||||
name="template_section"
|
||||
type="text"
|
||||
label="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION"
|
||||
description="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION_DESC"
|
||||
default="body_content"
|
||||
showon="template_id!:"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
|
||||
@@ -95,30 +95,14 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) {
|
||||
if ($httpCode !== 200 || empty($data['id'])) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
$campaignId = $data['id'];
|
||||
|
||||
// Set campaign content — template injection or responsive wrapper
|
||||
$templateId = (int) $this->params->get('template_id', 0);
|
||||
$templateSection = $this->params->get('template_section', 'body_content');
|
||||
|
||||
if ($templateId > 0) {
|
||||
// Inject article content into a saved Mailchimp template section
|
||||
$contentData = json_encode([
|
||||
'template' => [
|
||||
'id' => $templateId,
|
||||
'sections' => [
|
||||
$templateSection => $message,
|
||||
],
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
// Wrap in responsive email skeleton
|
||||
$contentData = json_encode(['html' => $this->wrapEmailHtml($message)]);
|
||||
}
|
||||
// Set campaign content (HTML)
|
||||
$contentData = json_encode(['html' => $message]);
|
||||
|
||||
$ch = curl_init("https://{$dc}.api.mailchimp.com/3.0/campaigns/{$campaignId}/content");
|
||||
curl_setopt_array($ch, [
|
||||
@@ -201,27 +185,6 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
|
||||
return end($parts) ?: 'us1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap content in a responsive email HTML skeleton.
|
||||
* Used when no Mailchimp template ID is configured.
|
||||
*/
|
||||
private function wrapEmailHtml(string $content): string
|
||||
{
|
||||
return '<!DOCTYPE html>'
|
||||
. '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>'
|
||||
. '<body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">'
|
||||
. '<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#f4f4f4;">'
|
||||
. '<tr><td align="center" style="padding:20px 0;">'
|
||||
. '<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#ffffff;border-radius:4px;">'
|
||||
. '<tr><td style="padding:30px 40px;">'
|
||||
. $content
|
||||
. '</td></tr>'
|
||||
. '</table>'
|
||||
. '</td></tr>'
|
||||
. '</table>'
|
||||
. '</body></html>';
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_MASTODON_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Mastodon</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mastodon">mastodon.php</filename>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Mastodon\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -27,7 +26,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "access_token": "..."
|
||||
* }
|
||||
*/
|
||||
class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -53,46 +52,10 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuit
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']];
|
||||
}
|
||||
|
||||
// Build status payload with optional Mastodon features
|
||||
$postBody = ['status' => mb_substr($message, 0, 500)];
|
||||
|
||||
// Visibility: public (default), unlisted, private, direct
|
||||
$visibility = $params['visibility'] ?? $this->params->get('default_visibility', 'public');
|
||||
if (in_array($visibility, ['public', 'unlisted', 'private', 'direct'], true)) {
|
||||
$postBody['visibility'] = $visibility;
|
||||
}
|
||||
|
||||
// Content warning / spoiler text
|
||||
$spoiler = $params['spoiler_text'] ?? '';
|
||||
if (!empty($spoiler)) {
|
||||
$postBody['spoiler_text'] = $spoiler;
|
||||
}
|
||||
|
||||
// Scheduled posting (must be 5+ minutes in future)
|
||||
$scheduledAt = $params['scheduled_at'] ?? '';
|
||||
if (!empty($scheduledAt)) {
|
||||
$postBody['scheduled_at'] = $scheduledAt;
|
||||
}
|
||||
|
||||
// Poll support (mutually exclusive with media)
|
||||
if (!empty($params['poll']['options']) && empty($media)) {
|
||||
$postBody['poll'] = [
|
||||
'options' => $params['poll']['options'],
|
||||
'expires_in' => (int) ($params['poll']['expires_in'] ?? 86400),
|
||||
'multiple' => !empty($params['poll']['multiple']),
|
||||
];
|
||||
}
|
||||
|
||||
// Language tag
|
||||
$language = $params['language'] ?? '';
|
||||
if (!empty($language)) {
|
||||
$postBody['language'] = $language;
|
||||
}
|
||||
|
||||
$ch = curl_init($instance . '/api/v1/statuses');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($postBody),
|
||||
CURLOPT_POSTFIELDS => json_encode(['status' => mb_substr($message, 0, 500)]),
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
@@ -157,46 +120,6 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuit
|
||||
return ['valid' => false, 'message' => 'Failed', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$instance = rtrim($credentials['instance_url'] ?? '', '/');
|
||||
$token = $credentials['access_token'] ?? '';
|
||||
|
||||
if (empty($instance) || empty($token)) {
|
||||
return ['success' => false, 'message' => 'Missing credentials.'];
|
||||
}
|
||||
|
||||
$ch = curl_init($instance . '/api/v1/statuses/' . $platformPostId);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
return ['success' => true, 'message' => 'Status deleted successfully.'];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
return ['success' => false, 'message' => $data['error'] ?? 'Delete failed with HTTP ' . $httpCode];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -163,7 +163,7 @@ class MediumService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return '';
|
||||
return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
|
||||
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
PLG_MOKOSUITECROSS_NTFY="MokoSuiteCross - Ntfy Push Notifications"
|
||||
PLG_MOKOSUITECROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy push notifications. Default server: ntfy.mokoconsulting.tech."
|
||||
PLG_MOKOSUITECROSS_NTFY_FIELDSET_DEFAULTS="Ntfy Defaults"
|
||||
PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL="Default Server URL"
|
||||
PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL_DESC="Default ntfy server URL. Override per-service in credentials. Default: https://ntfy.mokoconsulting.tech"
|
||||
PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC="Default Topic"
|
||||
PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC_DESC="Default ntfy topic name. Each service can override this in its credentials."
|
||||
PLG_MOKOSUITECROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy Push Notifications."
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -23,25 +23,4 @@
|
||||
<language tag="en-GB">language/en-GB/plg_mokosuitecross_ntfy.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_mokosuitecross_ntfy.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="PLG_MOKOSUITECROSS_NTFY_FIELDSET_DEFAULTS">
|
||||
<field
|
||||
name="default_server_url"
|
||||
type="url"
|
||||
label="PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL"
|
||||
description="PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL_DESC"
|
||||
default="https://ntfy.mokoconsulting.tech"
|
||||
/>
|
||||
<field
|
||||
name="default_topic"
|
||||
type="text"
|
||||
label="PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC"
|
||||
description="PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC_DESC"
|
||||
hint="e.g. mokosuite-articles"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -43,8 +43,7 @@ class NtfyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCro
|
||||
{
|
||||
$url = $credentials['topic'] ?? $credentials['webhook_url'] ?? '';
|
||||
|
||||
$defaultServer = $this->params->get('default_server_url', 'https://ntfy.mokoconsulting.tech');
|
||||
$serverUrl = rtrim($credentials['server_url'] ?? $defaultServer, '/');
|
||||
$serverUrl = rtrim($credentials['server_url'] ?? 'https://ntfy.sh', '/');
|
||||
$topic = $credentials['topic'] ?? '';
|
||||
$token = $credentials['token'] ?? '';
|
||||
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -10,7 +10,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOSUITECROSS_SLACK_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Slack</namespace>
|
||||
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="slack">slack.php</filename>
|
||||
|
||||
@@ -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.00</version>
|
||||
<version>01.02.00-rc</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Telegram\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
@@ -32,7 +31,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* "chat_id": "-100xxxxxxx" // Required — channel/group/user chat ID
|
||||
* }
|
||||
*/
|
||||
class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
|
||||
class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
|
||||
{
|
||||
/**
|
||||
* Default MokoSuite Bot token — resolved at runtime from component params.
|
||||
@@ -223,52 +222,6 @@ class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuit
|
||||
return $r;
|
||||
}
|
||||
|
||||
public function deletePost(string $platformPostId, array $credentials): array
|
||||
{
|
||||
$botToken = $this->resolveBotToken($credentials);
|
||||
$chatId = $credentials['chat_id'] ?? '';
|
||||
|
||||
if (empty($botToken) || empty($chatId)) {
|
||||
return ['success' => false, 'message' => 'Missing bot token or chat_id.'];
|
||||
}
|
||||
|
||||
$apiUrl = 'https://api.telegram.org/bot' . $botToken . '/deleteMessage';
|
||||
|
||||
$postData = json_encode([
|
||||
'chat_id' => $chatId,
|
||||
'message_id' => (int) $platformPostId,
|
||||
]);
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode === 200 && !empty($data['ok'])) {
|
||||
return ['success' => true, 'message' => 'Message deleted successfully.'];
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $data['description'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'document'];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user