diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index af023d1..0ae588f 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -2,9 +2,10 @@ MokoJoomCross + Package - MokoJoomCross MokoConsulting Cross-posting Joomla content to social media, email marketing, and chat platforms - 01.00.00-dev + 01.00.13 GNU General Public License v3 diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml deleted file mode 100644 index bfbd29a..0000000 --- a/.mokogitea/workflows/auto-bump.yml +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# 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) - -name: "Universal: Auto Version Bump" - -on: - push: - branches: - - dev - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - -permissions: - contents: write - -jobs: - bump: - name: Version Bump - runs-on: release - if: >- - !contains(github.event.head_commit.message, '[skip ci]') && - !contains(github.event.head_commit.message, '[skip bump]') && - !startsWith(github.event.head_commit.message, 'Merge pull request') - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.GA_TOKEN }} - fetch-depth: 1 - - - 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/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.GA_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 - run: | - BUMP=$(php ${MOKO_CLI}/version_bump.php --path . 2>&1) || true - echo "$BUMP" - - VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) || true - [ -z "$VERSION" ] && { echo "No version found — skipping"; exit 0; } - - # Propagate to platform manifests - php ${MOKO_CLI}/version_set_platform.php \ - --path . --version "$VERSION" --branch dev 2>/dev/null || true - php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true - - # Commit if anything changed - if git diff --quiet && git diff --cached --quiet; then - echo "No version changes to commit" - exit 0 - fi - - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - git add -A - git commit -m "chore(version): patch bump to ${VERSION} [skip ci]" \ - --author="gitea-actions[bot] " - git push origin dev - echo "Bumped to ${VERSION}" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index ef09563..44a2d64 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -1,492 +1,285 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Release -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform -# 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. | -# | | -# | Platform-specific: | -# | joomla: XML manifest, updates.xml, type-prefixed packages | -# | dolibarr: mod*.class.php, update.txt, dev version reset | -# | generic: README-only, no update stream | -# | | -# +========================================================================+ - -name: "Universal: Build & Release" - -on: - pull_request: - types: [opened, closed] - branches: - - main - workflow_dispatch: - inputs: - action: - description: 'Action to perform' - required: false - type: choice - default: release - options: - - release - - promote-rc - -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: - # ── Draft PR → Promote highest pre-release to RC ───────────────────────────── - promote-rc: - name: Promote Pre-Release to RC - runs-on: release - if: >- - (github.event.action == 'opened' && github.event.pull_request.draft == true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.GA_TOKEN }} - fetch-depth: 1 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - 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 - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - name: Promote to release-candidate - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_promote.php \ - --from auto --to release-candidate \ - --token "${{ secrets.GA_TOKEN }}" \ - --api-base "${API_BASE}" \ - --branch "${{ github.event.pull_request.head.ref || 'dev' }}" - - - name: Cascade lesser channels - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_cascade.php \ - --stability release-candidate \ - --token "${{ secrets.GA_TOKEN }}" \ - --api-base "${API_BASE}" - - - name: Summary - if: always() - run: | - echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY - echo "Draft PR opened — promoted highest pre-release to RC" >> $GITHUB_STEP_SUMMARY - - # ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── - release: - name: Build & Release Pipeline - runs-on: release - if: >- - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.GA_TOKEN }} - fetch-depth: 0 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' - run: | - # Ensure PHP + Composer are available - 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 - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - # -- PLATFORM DETECTION --------------------------------------------------- - - name: Detect platform - id: platform - run: | - php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true) - echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" - echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" - - - name: "Step 1: Read version" - id: version - run: | - VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .) - if [ -z "$VERSION" ]; then - echo "::error::No VERSION in README.md" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - MAJOR=$(echo "$VERSION" | cut -d. -f1) - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "release_tag=stable" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "branch=main" >> "$GITHUB_OUTPUT" - - # -- CHECK FOR RC PROMOTION ------------------------------------------------ - - name: "Check for RC release" - id: rc - if: steps.version.outputs.skip != 'true' - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - RC_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - "${API_BASE}/releases/tags/release-candidate" 2>/dev/null || echo "{}") - RC_ID=$(echo "$RC_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true) - - if [ -n "$RC_ID" ] && [ "$RC_ID" != "None" ] && [ "$RC_ID" != "" ]; then - echo "promote=true" >> "$GITHUB_OUTPUT" - echo "release_id=${RC_ID}" >> "$GITHUB_OUTPUT" - echo "::notice::RC release found (id: ${RC_ID}) — will promote to stable" - else - echo "promote=false" >> "$GITHUB_OUTPUT" - echo "::notice::No RC release — full build pipeline" - fi - - - name: "Step 1b: Minor bump version" - id: bump - if: >- - steps.version.outputs.skip != 'true' && - steps.rc.outputs.promote != 'true' - run: | - MOKO_API="/tmp/moko-platform-api/cli" - php ${MOKO_API}/version_bump.php --path . --minor 2>&1 || true - VERSION=$(php ${MOKO_API}/version_read.php --path .) - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "Bumped to: ${VERSION}" - - - name: Check if already released - if: steps.version.outputs.skip != 'true' - id: check - run: | - TAG="${{ steps.version.outputs.release_tag }}" - BRANCH="${{ steps.version.outputs.branch }}" - - TAG_EXISTS=false - BRANCH_EXISTS=false - - git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true - git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true - - echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT" - echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT" - - # Tag and branch may persist across patch releases — never skip - echo "already_released=false" >> "$GITHUB_OUTPUT" - - # -- SANITY CHECKS ------------------------------------------------------- - - name: "Sanity: Pre-release validation" - if: >- - steps.version.outputs.skip != 'true' && - steps.check.outputs.already_released != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - php /tmp/moko-platform-api/cli/release_validate.php \ - --path . --version "$VERSION" --output-summary --github-output || true - - # -- STEP 2: Create or update version/XX.YY archive branch --------------- - # Always runs — every version change on main archives to version/XX.YY - - name: "Step 2: Version archive branch" - if: steps.check.outputs.already_released != 'true' - run: | - BRANCH="${{ steps.version.outputs.branch }}" - IS_MINOR="${{ steps.version.outputs.is_minor }}" - PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}') - - # Check if branch exists - if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then - git push origin HEAD:"$BRANCH" --force - echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY - else - git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" - git push origin "$BRANCH" --force - echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY - fi - - # -- STEP 3: Set platform version ---------------------------------------- - - name: "Step 3: Set platform version" - if: >- - steps.version.outputs.skip != 'true' && - steps.check.outputs.already_released != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - php /tmp/moko-platform-api/cli/version_set_platform.php \ - --path . --version "$VERSION" --branch main - - # -- STEP 4: Update version badges ---------------------------------------- - - name: "Step 4: Update version badges" - if: steps.version.outputs.skip != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true - php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true - - # Step 5 (updates.xml) moved after Step 8 to include SHA-256 checksum - - - name: Commit release changes - if: >- - steps.version.outputs.skip != 'true' && - steps.check.outputs.already_released != 'true' - run: | - if git diff --quiet && git diff --cached --quiet; then - echo "No changes to commit" - exit 0 - fi - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - # Set push URL with token for branch-protected repos - git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - git add -A - git commit -m "chore(release): build ${VERSION} [skip ci]" \ - --author="gitea-actions[bot] " - git push -u origin HEAD - - # -- STEP 6: Create tag --------------------------------------------------- - - name: "Step 6: Create git tag" - if: >- - steps.version.outputs.skip != 'true' - run: | - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - # Only create the major release tag if it doesn't exist yet - if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - git tag "$RELEASE_TAG" - git push origin "$RELEASE_TAG" - echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY - else - echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY - fi - echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY - - # -- STEP 7a: Promote RC to stable (skip build) ---------------------------- - - name: "Step 7a: Promote RC to stable" - if: >- - steps.version.outputs.skip != 'true' && - steps.rc.outputs.promote == 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_promote.php \ - --from release-candidate --to stable \ - --token "${{ secrets.GA_TOKEN }}" \ - --api-base "${API_BASE}" \ - --path . --branch main - echo "Promoted RC → stable (${VERSION})" >> $GITHUB_STEP_SUMMARY - - # -- STEP 7b: Create or update Gitea Release (full build path) ------------- - - name: "Step 7b: Gitea Release" - if: >- - steps.version.outputs.skip != 'true' && - steps.rc.outputs.promote != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_create.php \ - --path . --version "$VERSION" --tag "$RELEASE_TAG" \ - --token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --branch main - echo "Release created: ${VERSION}" >> $GITHUB_STEP_SUMMARY - - # -- STEP 8: Build packages and upload to release ---------------------------- - - name: "Step 8: Build package and upload" - id: package - if: >- - steps.version.outputs.skip != 'true' && - steps.rc.outputs.promote != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_package.php \ - --path . --version "$VERSION" --tag "$RELEASE_TAG" \ - --token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --output /tmp || true - - # -- STEP 5: Write update stream (after build so SHA-256 is available) ----- - - name: "Step 5: Write update stream" - if: steps.version.outputs.skip != 'true' - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - SHA256="${{ steps.package.outputs.sha256_zip }}" - - # Fetch latest updates.xml from main so preserve logic has all channels - GA_TOKEN="${{ secrets.GA_TOKEN }}" - API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" - curl -sf -H "Authorization: token ${GA_TOKEN}" \ - "${API}/contents/updates.xml?ref=main" 2>/dev/null | \ - python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \ - > updates.xml 2>/dev/null || true - - SHA_FLAG="" - [ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}" - - php /tmp/moko-platform-api/cli/updates_xml_build.php \ - --path . --version "${VERSION}" --stability stable \ - --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ - ${SHA_FLAG} --github-output - - # Commit updates.xml if changed - if ! git diff --quiet updates.xml 2>/dev/null; then - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - git add updates.xml - git commit -m "chore: update stable channel ${VERSION} [skip ci]" \ - --author="gitea-actions[bot] " - git push origin HEAD 2>&1 || true - fi - - # -- STEP 8b: Update release description with changelog ---------------------- - - name: "Step 8b: Update release body" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - php /tmp/moko-platform-api/cli/release_body_update.php \ - --path . --version "${VERSION}" --tag "${RELEASE_TAG}" \ - --token "${{ secrets.GA_TOKEN }}" \ - --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ - 2>&1 || true - echo "Release body updated" >> $GITHUB_STEP_SUMMARY - - # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- - - name: "Step 9: Mirror release to GitHub" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_TOKEN != '' - continue-on-error: true - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_mirror.php \ - --version "$VERSION" --tag "$RELEASE_TAG" \ - --token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \ - --gh-token "${{ secrets.GH_TOKEN }}" --gh-repo "$GH_REPO" \ - --branch main 2>&1 || true - echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY - - # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- - - name: "Step 10: Push main to GitHub mirror" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_TOKEN != '' - continue-on-error: true - run: | - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) - GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) - git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ - git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" - git fetch origin main --depth=1 - git push github origin/main:refs/heads/main --force 2>/dev/null \ - && echo "main branch pushed to GitHub mirror" \ - || echo "WARNING: GitHub mirror push failed" - - # -- Clean up lesser pre-releases (cascade) --------------------------------- - # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev - - name: "Delete lesser pre-release channels" - continue-on-error: true - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_cascade.php \ - --stability stable \ - --version "${VERSION}" \ - --token "${{ secrets.GA_TOKEN }}" \ - --api-base "${API_BASE}" 2>/dev/null || true - - - name: "Step 11: Delete and recreate dev branch from main" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.GA_TOKEN }}" - - # Delete dev branch - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" - - # Recreate dev from main (now includes version bump + changelog promotion) - curl -sf -X POST -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - "${API_BASE}/branches" \ - -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" - - echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY - - - # -- Dolibarr post-release: Reset dev version ----------------------------- - - name: "Post-release: Reset dev version" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/version_reset_dev.php \ - --token "${{ secrets.GA_TOKEN }}" --api-base "${API_BASE}" \ - --branch dev --path . 2>&1 || true - - # -- Summary -------------------------------------------------------------- - - name: Pipeline Summary - if: always() - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - PLATFORM="${{ steps.platform.outputs.platform }}" - if [ "${{ steps.version.outputs.skip }}" = "true" ]; then - echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY - echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY - elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then - echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY - echo "|------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY - fi +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# 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. | +# | | +# | Platform-specific: | +# | joomla: XML manifest, updates.xml, type-prefixed packages | +# | dolibarr: mod*.class.php, update.txt, dev version reset | +# | generic: README-only, no update stream | +# | | +# +========================================================================+ + +name: "Universal: Build & Release" + +on: + pull_request: + types: [opened, closed] + branches: + - main + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: false + type: choice + default: release + options: + - release + - promote-rc + +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: + # ── PR Opened → Rename branch to RC and build RC release ───────────────────── + promote-rc: + name: Promote to RC + runs-on: release + if: >- + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + 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-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + + - name: Rename branch to rc + run: | + php /tmp/moko-platform-api/cli/branch_rename.php \ + --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ + --pr "${{ github.event.pull_request.number }}" + + - name: Checkout rc and configure git + run: | + git fetch origin rc + git checkout rc + 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" + + - name: Publish RC release + run: | + php /tmp/moko-platform-api/cli/release_publish.php \ + --path . --stability rc --bump minor --branch rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + - name: Summary + if: always() + run: | + echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY + echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY + + # ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── + release: + name: Build & Release Pipeline + runs-on: release + if: >- + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 0 + + - name: Configure git for bot pushes + run: | + 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" + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found — aborting release" + echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Setup moko-platform 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: | + # Ensure PHP + Composer are available + 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-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + + + - name: "Publish stable release" + run: | + php /tmp/moko-platform-api/cli/release_publish.php \ + --path . --stability stable --bump minor --branch main \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- + - name: "Step 9: Mirror release to GitHub" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php /tmp/moko-platform-api/cli/release_mirror.php \ + --version "$VERSION" --tag "$RELEASE_TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ + --branch main 2>&1 || true + echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY + + # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- + - name: "Step 10: Push main to GitHub mirror" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) + GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) + git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ + git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" + git fetch origin main --depth=1 + git push github origin/main:refs/heads/main --force 2>/dev/null \ + && echo "main branch pushed to GitHub mirror" \ + || echo "WARNING: GitHub mirror push failed" + + - name: "Step 11: Delete rc branch and recreate dev from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Delete rc branch (ephemeral — created by promote-rc) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/rc" 2>/dev/null \ + && echo "Deleted rc branch" || echo "rc branch not found" + + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" + + # Recreate dev from main (now includes version bump + changelog promotion) + curl -sf -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" + + echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY + + - name: "Step 12: Create version branch from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + BRANCH_NAME="version/${VERSION}" + MAIN_SHA=$(git rev-parse HEAD) + + # Delete old version branch if it exists (same version re-release) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" + + # Create version/XX.YY.ZZ from main + curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" + + echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY + + + + # -- Dolibarr post-release: Reset dev version ----------------------------- + - name: "Post-release: Reset dev version" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php /tmp/moko-platform-api/cli/version_reset_dev.php \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ + --branch dev --path . 2>&1 || true + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + if: always() + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + PLATFORM="${{ steps.platform.outputs.platform }}" + if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY + echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then + echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml deleted file mode 100644 index 7f26935..0000000 --- a/.mokogitea/workflows/cascade-dev.yml +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Maintenance -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform -# PATH: /templates/workflows/cascade-dev.yml.template -# VERSION: 02.00.00 -# BRIEF: Forward-merge main -> all open branches after every push to main -# -# +========================================================================+ -# | CASCADE MAIN -> ALL BRANCHES | -# +========================================================================+ -# | | -# | Triggers on every push to main (PR merges, bot commits, etc.) | -# | | -# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* | -# | 2. For each: create PR (main -> branch), auto-merge if clean | -# | 3. On conflict: leave PR open for manual resolution | -# | | -# +========================================================================+ - -name: "Universal: Cascade Main -> Dev" - -on: - push: - branches: - - main - workflow_dispatch: - -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 - pull-requests: write - -jobs: - cascade: - name: Cascade main -> branches - runs-on: ubuntu-latest - if: >- - !contains(github.event.head_commit.message, '[skip ci]') && - !contains(github.event.head_commit.message, '[skip cascade]') - - steps: - - name: Discover target branches - id: branches - env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} - run: | - API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - - # Fetch all branches (paginated) - PAGE=1 - ALL_BRANCHES="" - while true; do - BATCH=$(curl -sS \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/branches?page=${PAGE}&limit=50" \ - | jq -r '.[].name // empty') - [ -z "$BATCH" ] && break - ALL_BRANCHES="$ALL_BRANCHES $BATCH" - PAGE=$((PAGE + 1)) - done - - # Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/* - TARGETS="" - for BRANCH in $ALL_BRANCHES; do - case "$BRANCH" in - dev|dev/*|rc/*|beta/*|alpha/*) - TARGETS="$TARGETS $BRANCH" - ;; - esac - done - - TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace - - if [ -z "$TARGETS" ]; then - echo "targets=" >> "$GITHUB_OUTPUT" - echo " No cascade target branches found" - else - echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" - COUNT=$(echo "$TARGETS" | wc -w) - echo " Found ${COUNT} target branch(es): ${TARGETS}" - fi - - - name: Cascade to all target branches - if: steps.branches.outputs.targets != '' - env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} - run: | - API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - SHORT_SHA="${GITHUB_SHA:0:7}" - TARGETS="${{ steps.branches.outputs.targets }}" - - SUCCESS=0 - CONFLICTS=0 - SKIPPED=0 - FAILED=0 - - for BRANCH in $TARGETS; do - echo "" - echo " main -> ${BRANCH} " - - # Check if branch is already up to date - ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g') - RESPONSE=$(curl -sS \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/compare/${ENCODED_BRANCH}...main") - - AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0') - - if [ "$AHEAD" -eq 0 ]; then - echo " Already up to date" - SKIPPED=$((SKIPPED + 1)) - continue - fi - - echo " main is ${AHEAD} commit(s) ahead" - - # Check for existing cascade PR - EXISTING=$(curl -sS \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1") - - EXISTING_COUNT=$(echo "$EXISTING" | jq 'length') - PR_NUMBER="" - - if [ "$EXISTING_COUNT" -gt 0 ]; then - PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number') - echo " Reusing existing PR #${PR_NUMBER}" - else - # Create cascade PR - PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \ - -X POST \ - -H "Authorization: token ${GA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{ - \"title\": \"chore: cascade main -> ${BRANCH} (${SHORT_SHA}) [skip ci]\", - \"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main -> Dev**.\", - \"head\": \"main\", - \"base\": \"${BRANCH}\" - }" \ - "${API}/pulls") - - HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1) - BODY=$(echo "$PR_RESPONSE" | sed '$d') - PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty') - - if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then - MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1) - echo " Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}" - FAILED=$((FAILED + 1)) - continue - fi - - echo " Created PR #${PR_NUMBER}" - fi - - # Try auto-merge - PR_DATA=$(curl -sS \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/pulls/${PR_NUMBER}") - - MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false') - - if [ "$MERGEABLE" != "true" ]; then - echo " Conflicts -- PR #${PR_NUMBER} left open" - CONFLICTS=$((CONFLICTS + 1)) - continue - fi - - MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \ - -X POST \ - -H "Authorization: token ${GA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{ - \"Do\": \"merge\", - \"merge_message_field\": \"chore: cascade main -> ${BRANCH} [skip ci]\", - \"delete_branch_after_merge\": false - }" \ - "${API}/pulls/${PR_NUMBER}/merge") - - MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1) - - if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then - echo " Merged -- ${BRANCH} is in sync" - SUCCESS=$((SUCCESS + 1)) - else - MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d') - echo " Merge failed (HTTP ${MERGE_HTTP}) -- PR #${PR_NUMBER} left open" - CONFLICTS=$((CONFLICTS + 1)) - fi - done - - # Summary - echo "" - echo "" - echo " Merged: ${SUCCESS}" - echo " Conflicts: ${CONFLICTS}" - echo " Up to date: ${SKIPPED}" - echo " Failed: ${FAILED}" - echo "" - - if [ "$FAILED" -gt 0 ]; then - exit 1 - fi diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml index ad232e4..a79b335 100644 --- a/.mokogitea/workflows/ci-joomla.yml +++ b/.mokogitea/workflows/ci-joomla.yml @@ -47,9 +47,9 @@ jobs: - name: Clone MokoStandards env: - GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} run: | git clone --depth 1 --branch main --quiet \ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ @@ -57,7 +57,7 @@ jobs: - name: Install dependencies env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}' run: | if [ -f "composer.json" ]; then composer install \ @@ -354,7 +354,7 @@ jobs: - name: Install dependencies env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}' run: | if [ -f "composer.json" ]; then composer install \ @@ -404,7 +404,7 @@ jobs: - name: Install dependencies env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}' run: | if [ -f "composer.json" ]; then composer install --no-interaction --prefer-dist --optimize-autoloader diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index c2b02a6..f084fe1 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -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 }}" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml new file mode 100644 index 0000000..4d78d7a --- /dev/null +++ b/.mokogitea/workflows/pr-check.yml @@ -0,0 +1,508 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/pr-check.yml.template +# VERSION: 09.23.00 +# BRIEF: PR gate — branch policy + code validation before merge + +name: "Universal: PR Check" + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # ── Branch Policy ────────────────────────────────────────────────────── + branch-policy: + name: Branch Policy + runs-on: ubuntu-latest + steps: + - name: Check branch merge target + run: | + HEAD="${{ github.head_ref }}" + BASE="${{ github.base_ref }}" + + echo "PR: ${HEAD} → ${BASE}" + + ALLOWED=true + REASON="" + + case "$HEAD" in + feature/*|feat/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Feature branches must target 'dev', not '${BASE}'" + fi + ;; + fix/*|bugfix/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Fix branches must target 'dev', not '${BASE}'" + fi + ;; + patch/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + ALLOWED=false + REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + rc) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="RC branch can only merge into 'main', not '${BASE}'" + fi + ;; + dev) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Dev branch can only merge into 'main', not '${BASE}'" + fi + ;; + esac + + if [ "$ALLOWED" = false ]; then + echo "::error::${REASON}" + echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "${REASON}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY + echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Branch policy: OK (${HEAD} → ${BASE})" + echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found in source files" + echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 + fi + + - name: PHP syntax check + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0) + echo "PHP lint: ${ERRORS} error(s)" + [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } + + - name: Joomla JEXEC guard check + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + # Skip vendor, node_modules, and index.html stub files + case "$file" in ./vendor/*|./node_modules/*) continue ;; esac + # Check first 10 lines for JEXEC or JPATH guard + if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + echo "::error file=${file}::Missing JEXEC guard: ${file}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" + echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "JEXEC guard: OK" + + - name: Joomla directory listing protection + if: steps.platform.outputs.platform == 'joomla' + run: | + MISSING=0 + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && exit 0 + while IFS= read -r dir; do + if [ ! -f "${dir}/index.html" ]; then + echo "::warning::Missing index.html in ${dir} (directory listing protection)" + MISSING=$((MISSING + 1)) + fi + done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") + if [ "$MISSING" -gt 0 ]; then + echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY + echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY + fi + echo "Directory protection: ${MISSING} missing (advisory)" + + - name: Joomla script file and asset checks + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && exit 0 + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check scriptfile exists if declared + SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) + if [ -n "$SCRIPTFILE" ]; then + if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then + echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" + ERRORS=$((ERRORS + 1)) + else + echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" + fi + fi + + # Require joomla.asset.json and validate it + ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ASSET_JSON" ]; then + echo "::error::joomla.asset.json not found — Joomla asset system is required" + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { + echo "::error::joomla.asset.json is not valid JSON" + ERRORS=$((ERRORS + 1)) + } + fi + echo "joomla.asset.json: valid" + fi + + # Validate all XML files in src/ are well-formed + XML_ERRORS=0 + if command -v php &> /dev/null; then + while IFS= read -r -d '' xmlfile; do + if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then + XML_ERRORS=$((XML_ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) + fi + if [ "$XML_ERRORS" -gt 0 ]; then + echo "::error::${XML_ERRORS} XML file(s) are malformed" + ERRORS=$((ERRORS + 1)) + else + echo "XML well-formedness: OK" + fi + + [ "$ERRORS" -gt 0 ] && exit 1 + echo "Joomla asset checks: OK" + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "::warning::No Joomla manifest found (WaaS site)" + exit 0 + fi + echo "Manifest: ${MANIFEST}" + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; } + fi + for ELEMENT in name version description; do + grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } + done + # Block legacy raw/branch update server URLs on MokoGitea + RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) + if [ -n "$RAW_URLS" ]; then + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "$RAW_URLS" + exit 1 + fi + echo "Joomla manifest valid" + ;; + dolibarr) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + if [ -z "$MOD_FILE" ]; then + echo "::error::No mod*.class.php found" + exit 1 + fi + echo "Dolibarr module: ${MOD_FILE}" + ;; + *) + echo "Generic platform — no manifest validation" + ;; + esac + + - name: Check update stream format + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + if [ -f "updates.xml" ]; then + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; } + fi + echo "updates.xml valid" + fi + ;; + dolibarr) + [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" + ;; + esac + + - name: Validate Joomla language files + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + WARNINGS=0 + + # Require both en-GB and en-US language directories + LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$LANG_ROOT" ]; then + echo "No language/ directory found — skipping" + exit 0 + fi + + if [ ! -d "$LANG_ROOT/en-GB" ]; then + echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" + ERRORS=$((ERRORS + 1)) + fi + if [ ! -d "$LANG_ROOT/en-US" ]; then + echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" + ERRORS=$((ERRORS + 1)) + fi + + # Check that en-GB and en-US have matching .ini files + if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then + for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do + [ ! -f "$GB_INI" ] && continue + US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" + if [ ! -f "$US_INI" ]; then + echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" + ERRORS=$((ERRORS + 1)) + fi + done + for US_INI in "$LANG_ROOT/en-US"/*.ini; do + [ ! -f "$US_INI" ] && continue + GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" + if [ ! -f "$GB_INI" ]; then + echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # Find all .ini language files + INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) + if [ -z "$INI_FILES" ]; then + echo "No .ini language files found" + [ "$ERRORS" -gt 0 ] && exit 1 + exit 0 + fi + + echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" + + for FILE in $INI_FILES; do + FNAME=$(basename "$FILE") + LINENUM=0 + SEEN_KEYS="" + + while IFS= read -r line || [ -n "$line" ]; do + LINENUM=$((LINENUM + 1)) + + # Skip empty lines and comments + [ -z "$line" ] && continue + echo "$line" | grep -qE '^\s*;' && continue + echo "$line" | grep -qE '^\s*$' && continue + + # Must match KEY="VALUE" format + if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then + echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" + ERRORS=$((ERRORS + 1)) + continue + fi + + # Extract key and check for duplicates + KEY=$(echo "$line" | sed 's/=.*//') + if echo "$SEEN_KEYS" | grep -qx "$KEY"; then + echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" + ERRORS=$((ERRORS + 1)) + fi + SEEN_KEYS="${SEEN_KEYS} + ${KEY}" + done < "$FILE" + + echo " ${FILE}: checked ${LINENUM} lines" + done + + # Cross-check en-GB vs en-US key consistency + GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) + US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) + + if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then + for GB_FILE in "$GB_DIR"/*.ini; do + [ ! -f "$GB_FILE" ] && continue + FNAME=$(basename "$GB_FILE") + US_FILE="$US_DIR/$FNAME" + [ ! -f "$US_FILE" ] && continue + + GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) + US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) + + # Keys in en-GB but not en-US + MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_US" ]; then + echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" + echo "$MISSING_US" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + + # Keys in en-US but not en-GB + MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_GB" ]; then + echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" + echo "$MISSING_GB" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + done + fi + + { + echo "### Language File Validation" + echo "| Metric | Count |" + echo "|---|---|" + echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" + echo "| Errors | ${ERRORS} |" + echo "| Warnings | ${WARNINGS} |" + } >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Language validation failed with ${ERRORS} error(s)" + exit 1 + fi + echo "Language files: OK (${WARNINGS} warning(s))" + + - name: Check changelog has unreleased entry + run: | + if [ ! -f "CHANGELOG.md" ]; then + echo "::warning::No CHANGELOG.md found" + exit 0 + fi + # Check for content under [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md missing [Unreleased] section" + exit 1 + fi + # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased + UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) + if [ "$UNRELEASED_CONTENT" -eq 0 ]; then + echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." + echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY + echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" + + - name: Verify package source + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::warning::No src/ or htdocs/ directory" + exit 0 + fi + FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) + echo "Source: ${FILE_COUNT} files" + [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } + + # ── Pre-Release RC Build ───────────────────────────────────────────────── + pre-release: + name: Build RC Package + runs-on: ubuntu-latest + needs: [branch-policy, validate] + + steps: + - name: Trigger RC pre-release + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY + + # ── Issue Reporter ────────────────────────────────────────────────────── + report-issues: + name: Report Issues + runs-on: ubuntu-latest + needs: [branch-policy, validate] + if: >- + always() && + needs.validate.result == 'failure' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml deleted file mode 100644 index 3aedb97..0000000 --- a/.mokogitea/workflows/pre-release.yml +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform -# PATH: /templates/workflows/universal/pre-release.yml.template -# VERSION: 05.01.00 -# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch - -name: "Universal: Pre-Release" - -on: - pull_request: - types: [closed] - branches: - - dev - workflow_dispatch: - inputs: - stability: - description: 'Pre-release channel' - required: true - type: choice - options: - - development - - alpha - - beta - - release-candidate - -permissions: - contents: write - -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: - build: - name: "Build Pre-Release (${{ inputs.stability || 'development' }})" - runs-on: release - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GA_TOKEN }} - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - 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 - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/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" - - - name: Detect platform - id: platform - run: | - php ${MOKO_CLI}/manifest_read.php --path . --github-output - - - name: Resolve metadata and bump version - id: meta - run: | - STABILITY="${{ inputs.stability || 'development' }}" - - case "$STABILITY" in - development) SUFFIX="-dev"; TAG="development" ;; - alpha) SUFFIX="-alpha"; TAG="alpha" ;; - beta) SUFFIX="-beta"; TAG="beta" ;; - release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; - esac - - # Read current version (bump already handled by push workflow) - VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) - [ -z "$VERSION" ] && VERSION="00.00.01" - - php ${MOKO_CLI}/version_set_platform.php \ - --path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true - - # Verify version consistency across all files - php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true - - # Commit version bump - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - git add -A - git diff --cached --quiet || { - git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" - git push origin HEAD 2>&1 - } - - # Auto-detect element via manifest_element.php - php ${MOKO_CLI}/manifest_element.php \ - --path . --version "$VERSION" --stability "$STABILITY" \ - --repo "${GITEA_REPO}" --github-output - - # Read back element outputs - EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) - ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) - [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') - [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip" - - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" - echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" - echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" - - echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" - - - name: Create release - id: release - run: | - TAG="${{ steps.meta.outputs.tag }}" - VERSION="${{ steps.meta.outputs.version }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php ${MOKO_CLI}/release_create.php \ - --path . --version "$VERSION" --tag "$TAG" \ - --token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --branch dev --prerelease - - - name: Build package and upload - id: package - run: | - VERSION="${{ steps.meta.outputs.version }}" - TAG="${{ steps.meta.outputs.tag }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php ${MOKO_CLI}/release_package.php \ - --path . --version "$VERSION" --tag "$TAG" \ - --token "${{ secrets.GA_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 - if ! git diff --quiet updates.xml 2>/dev/null; then - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git add updates.xml - git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]" - git push origin HEAD 2>&1 || echo "WARNING: push failed" - fi - - - name: "Sync updates.xml to all branches" - if: steps.platform.outputs.platform == 'joomla' - run: | - CURRENT_BRANCH="${{ github.ref_name }}" - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - - for BRANCH in main dev; do - [ "$BRANCH" = "$CURRENT_BRANCH" ] && continue - echo "Syncing updates.xml -> ${BRANCH}" - git fetch origin "${BRANCH}" 2>/dev/null || continue - git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue - git checkout "${CURRENT_BRANCH}" -- updates.xml - if ! git diff --quiet updates.xml 2>/dev/null; then - git add updates.xml - git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]" - git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed" - fi - git checkout "${CURRENT_BRANCH}" 2>/dev/null - done - - - name: "Delete lesser pre-release channels (cascade)" - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.GA_TOKEN }}" - - php ${MOKO_CLI}/release_cascade.php \ - --stability "${{ steps.meta.outputs.stability }}" \ - --token "${TOKEN}" \ - --api-base "${API_BASE}" - - - name: Summary - if: always() - run: | - VERSION="${{ steps.meta.outputs.version }}" - STABILITY="${{ steps.meta.outputs.stability }}" - ZIP_NAME="${{ steps.meta.outputs.zip_name }}" - SHA256="${{ steps.package.outputs.sha256_zip }}" - echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY - echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY - echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml new file mode 100644 index 0000000..8d57aaf --- /dev/null +++ b/.mokogitea/workflows/repo-health.yml @@ -0,0 +1,711 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# 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. +# ============================================================================ + +name: "Generic: Repo Health" + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: 'Validation profile: all, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - scripts + - repo + pull_request: + push: + +permissions: + contents: read + +env: + # Scripts governance policy + SCRIPTS_REQUIRED_DIRS: + SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate + + # Repo health policy + REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # File / directory variables + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .mokogitea/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission (admin only) + id: perm + env: + TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + ALLOWED=false + PERMISSION=unknown + METHOD="" + + # Hardcoded authorized users — always allowed + case "$ACTOR" in + jmiller|gitea-actions[bot]) + ALLOWED=true + PERMISSION=admin + METHOD="hardcoded allowlist" + ;; + *) + # Detect platform and check permissions via API + API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}" + RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}') + PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown") + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then + ALLOWED=true + fi + METHOD="collaborator API" + ;; + esac + + echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT" + echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT" + + { + echo "## Access Authorization" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Actor** | \`${ACTOR}\` |" + echo "| **Repository** | \`${REPO}\` |" + echo "| **Permission** | \`${PERMISSION}\` |" + echo "| **Method** | ${METHOD} |" + echo "| **Authorized** | ${ALLOWED} |" + echo "" + if [ "$ALLOWED" = "true" ]; then + echo "${ACTOR} authorized (${METHOD})" + else + echo "${ACTOR} is NOT authorized. Requires admin or maintain role." + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Deny execution when not permitted + if: ${{ steps.perm.outputs.allowed != 'true' }} + run: | + set -euo pipefail + printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" + exit 1 + + scripts_governance: + name: Scripts governance + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Scripts folder checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi + IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" + IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" + if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi + IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}" + + missing_required=() + missing_optional=() + + # Source directory: src/ or htdocs/ (either is valid for extension repos) + SOURCE_DIR="" + if [ -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/ + SOURCE_DIR="" + else + missing_required+=("src/ or htdocs/ (source directory required)") + fi + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = 'dev' ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + fi + done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//') + + if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then + missing_required+=("dev or dev/* branch") + fi + + content_warnings=() + + if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + fi + + if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export PROFILE_RAW="${profile}" + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")" + export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")" + + report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}") + + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # -- Joomla-specific checks -- + joomla_findings=() + + MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)" + if [ -z "${MANIFEST}" ]; then + joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") + else + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then + joomla_findings+=("XML manifest: type attribute missing or invalid") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP ' missing (required for Joomla 5+)") + fi + fi + + INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)" + if [ "${INI_COUNT}" -eq 0 ]; then + joomla_findings+=("No .ini language files found") + fi + + if [ ! -f 'updates.xml' ]; then + joomla_findings+=("updates.xml missing in root (required for Joomla update server)") + fi + + if [ -n "${SOURCE_DIR}" ]; then + INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site") + for dir in "${INDEX_DIRS[@]}"; do + if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then + joomla_findings+=("${dir}/index.html missing (directory listing protection)") + fi + done + fi + + if [ "${#joomla_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' '| Check | Status |' + printf '%s\n' '|---|---|' + for f in "${joomla_findings[@]}"; do + printf '%s\n' "| ${f} | Warning |" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' 'All Joomla-specific checks passed.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -f "${DOCS_INDEX}" ]; then + missing_links="" + while IFS= read -r docline; do + for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do + case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac + linkpath="${link%%#*}" + linkpath="${linkpath%%\?*}" + [ -z "$linkpath" ] && continue + if [ "${linkpath:0:1}" = "/" ]; then + testpath="${linkpath#/}" + else + testpath="$(dirname "${DOCS_INDEX}")/${linkpath}" + fi + [ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} " + done + done < "${DOCS_INDEX}" + if [ -n "${missing_links}" ]; then + extended_findings+=("docs/docs-index.md contains broken relative links") + { + printf '%s\n' '### Docs index link integrity' + printf '%s\n' 'Broken relative links:' + for bl in ${missing_links}; do + printf '%s\n' "- ${bl}" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + printf '%s\n' '| Domain | Status | Notes |' + printf '%s\n' '|---|---|---|' + printf '%s\n' '| Access control | OK | Admin-only execution gate |' + printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |' + printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' + printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' + printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" + + + site-health: + name: Site Health + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Uptime check + if: env.URLS != '' + run: | + echo "$URLS" > /tmp/urls.txt + php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down" + rm -f /tmp/urls.txt + env: + URLS: ${{ vars.MONITORED_URLS }} + + - name: SSL certificate check + if: env.DOMAINS != '' + run: | + echo "$DOMAINS" > /tmp/domains.txt + php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon" + rm -f /tmp/domains.txt + env: + DOMAINS: ${{ vars.MONITORED_DOMAINS }} + + - name: Summary + if: always() + run: | + echo "### Site Health" >> $GITHUB_STEP_SUMMARY + echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY + + # ═══════════════════════════════════════════════════════════════════════ + # Issue Reporter — file issues for failed gates + # ═══════════════════════════════════════════════════════════════════════ + report-issues: + name: "Report Issues" + runs-on: ubuntu-latest + needs: [access_check, scripts_governance, repo_health] + if: >- + always() && + (needs.scripts_governance.result == 'failure' || + needs.repo_health.result == 'failure') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issues for failed gates" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + REPORTER="./automation/ci-issue-reporter.sh" + WF="Repo Health" + + report_gate() { + local gate="$1" result="$2" details="$3" + if [ "$result" = "failure" ]; then + "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error + fi + } + + report_gate "Scripts Governance" \ + "${{ needs.scripts_governance.result }}" \ + "Scripts directory policy violations detected. Review required and allowed directories." + + report_gate "Repository Health" \ + "${{ needs.repo_health.result }}" \ + "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml deleted file mode 100644 index c77cdaa..0000000 --- a/.mokogitea/workflows/update-server.yml +++ /dev/null @@ -1,660 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Universal -# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform -# PATH: /templates/workflows/update-server.yml -# VERSION: 04.07.00 -# BRIEF: Update server XML feed with stable/rc/beta/alpha/dev entries (universal) -# -# Writes updates.xml with multiple entries: -# - stable on push to main (from auto-release) -# - rc on push to rc/** -# - development on push to dev or dev/** -# -# Joomla filters by 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 updates.xml - 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.GA_TOKEN }} - fetch-depth: 0 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_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 - if [ -d "/tmp/moko-platform" ]; then - echo "moko-platform already available — skipping clone" - else - 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 - fi - 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 - - - name: Generate updates.xml entry - id: update - run: | - BRANCH="${{ github.ref_name }}" - REPO="${{ github.repository }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "0.0.0") - - # Auto-bump patch on all branches (dev, alpha, beta, rc) - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - BUMPED=$(php /tmp/moko-platform/cli/version_bump.php --path . 2>/dev/null || true) - if [ -n "$BUMPED" ]; then - VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "$VERSION") - git add -A - git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \ - --author="gitea-actions[bot] " 2>/dev/null || true - git push 2>/dev/null || true - fi - - # Determine stability from branch or 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" - elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then - STABILITY="development" - else - STABILITY="stable" - fi - - echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" - - # Parse manifest (portable — no grep -P) - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '/dev/null | head -1) - if [ -z "$MANIFEST" ]; then - echo "No Joomla manifest found — skipping" - exit 0 - fi - - # Extract fields using sed (works on all runners) - EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) - EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) - EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) - TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) - PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) - - # Fallbacks - [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" - [ -z "$EXT_TYPE" ] && EXT_TYPE="component" - - # Derive element if not in manifest: try XML filename, then repo name - if [ -z "$EXT_ELEMENT" ]; then - EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') - case "$EXT_ELEMENT" in - templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; - esac - fi - - # Use manifest version if README version is empty - [ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION" - - [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/") - - # Joomla requires on ALL extension types for update matching - if [ -n "$EXT_CLIENT" ]; then - CLIENT_TAG="${EXT_CLIENT}" - else - CLIENT_TAG="site" - fi - - FOLDER_TAG="" - [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}" - - PHP_TAG="" - [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM}" - - # Version suffix for non-stable - DISPLAY_VERSION="$VERSION" - case "$STABILITY" in - development) DISPLAY_VERSION="${VERSION}-dev" ;; - alpha) DISPLAY_VERSION="${VERSION}-alpha" ;; - beta) DISPLAY_VERSION="${VERSION}-beta" ;; - rc) DISPLAY_VERSION="${VERSION}-rc" ;; - esac - - MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') - - # Each stability level has its own release tag - case "$STABILITY" in - development) RELEASE_TAG="development" ;; - alpha) RELEASE_TAG="alpha" ;; - beta) RELEASE_TAG="beta" ;; - rc) RELEASE_TAG="release-candidate" ;; - *) RELEASE_TAG="v${MAJOR}" ;; - esac - - PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip" - DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" - INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}" - - # -- Build install packages (ZIP + tar.gz) -------------------- - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - if [ -d "$SOURCE_DIR" ]; then - EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" - TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz" - - cd "$SOURCE_DIR" - zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES - cd .. - tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ - --exclude='.ftpignore' --exclude='sftp-config*' \ - --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . - - SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1) - - # Ensure release exists on Gitea - RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true) - RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - - if [ -z "$RELEASE_ID" ]; then - # Create release - RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - -H "Content-Type: application/json" \ - "${API_BASE}/releases" \ - -d "$(python3 -c "import json; print(json.dumps({ - 'tag_name': '${RELEASE_TAG}', - 'name': '${RELEASE_TAG} (${DISPLAY_VERSION})', - 'body': '${STABILITY} release', - 'prerelease': True, - 'target_commitish': 'main' - }))")" 2>/dev/null || true) - RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - fi - - if [ -n "$RELEASE_ID" ]; then - # Delete existing assets with same name before uploading - ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]") - for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do - ASSET_ID=$(echo "$ASSETS" | python3 -c " - import sys,json - assets = json.load(sys.stdin) - for a in assets: - if a['name'] == '${ASSET_FILE}': - print(a['id']); break - " 2>/dev/null || true) - if [ -n "$ASSET_ID" ]; then - curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true - fi - done - - # Upload both formats - curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"/tmp/${PACKAGE_NAME}" \ - "${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true - - curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"/tmp/${TAR_NAME}" \ - "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true - fi - - echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY - else - SHA256="" - fi - - # -- Build the new entry (canonical format matching release.yml) -- - NEW_ENTRY="" - NEW_ENTRY="${NEW_ENTRY} \n" - NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME}\n" - NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} ${STABILITY} build.\n" - NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT}\n" - NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE}\n" - [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n" - [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n" - NEW_ENTRY="${NEW_ENTRY} ${VERSION}\n" - NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d)\n" - NEW_ENTRY="${NEW_ENTRY} https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}\n" - NEW_ENTRY="${NEW_ENTRY} \n" - NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL}\n" - NEW_ENTRY="${NEW_ENTRY} \n" - [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256}\n" - NEW_ENTRY="${NEW_ENTRY} ${STABILITY}\n" - NEW_ENTRY="${NEW_ENTRY} Moko Consulting\n" - NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech\n" - NEW_ENTRY="${NEW_ENTRY} \n" - [ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_MINIMUM}\n" - NEW_ENTRY="${NEW_ENTRY} " - - # -- Write new entry to temp file -------------------------------- - printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml - - # -- Merge into updates.xml ---------------------------------------- - # Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev - CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development" - TARGETS="" - for entry in $CASCADE_MAP; do - key="${entry%%:*}" - vals="${entry#*:}" - if [ "$key" = "${STABILITY}" ]; then - TARGETS="$vals" - break - fi - done - [ -z "$TARGETS" ] && TARGETS="${STABILITY}" - - echo "Cascade: ${STABILITY} → ${TARGETS}" - - # Create updates.xml if missing - if [ ! -f "updates.xml" ]; then - printf '%s\n' "" > updates.xml - printf '%s\n' "" >> updates.xml - printf '%s\n' "" >> updates.xml - printf '%s\n' "" >> updates.xml - fi - - # Update existing blocks or create missing ones - export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)" - python3 << 'PYEOF' - import re, os - - targets = os.environ["PY_TARGETS"].split(",") - version = os.environ["PY_VERSION"] - date = os.environ["PY_DATE"] - - with open("updates.xml") as f: - content = f.read() - with open("/tmp/new_entry.xml") as f: - new_entry_template = f.read() - - for tag in targets: - tag = tag.strip() - # Build entry with this tag's name - new_entry = re.sub(r"[^<]*", f"{tag}", new_entry_template) - - # Try to find existing block (handles both single-line and multi-line ) - block_pattern = r"((?:(?!).)*?" + re.escape(tag) + r".*?)" - match = re.search(block_pattern, content, re.DOTALL) - - if match: - # Update in place — replace entire block - content = content.replace(match.group(1), new_entry.strip()) - print(f" UPDATED: {tag} → {version}") - else: - # Create — insert before - content = content.replace("", "\n" + new_entry.strip() + "\n\n") - print(f" CREATED: {tag} → {version}") - - # Clean up excessive blank lines - content = re.sub(r"\n{3,}", "\n\n", content) - - with open("updates.xml", "w") as f: - f.write(content) - PYEOF - - # Commit - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git add updates.xml - git diff --cached --quiet || { - git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \ - --author="gitea-actions[bot] " - git push - } - - # -- Sync updates.xml to main (for non-main branches) ---------------------- - - name: Sync updates.xml to main - if: github.ref_name != 'main' - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - GA_TOKEN="${{ secrets.GA_TOKEN }}" - - FILE_SHA=$(curl -sf -H "Authorization: token ${GA_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 ${STABILITY} [skip ci]', - 'branch': 'main' - }).encode() - req = urllib.request.Request( - '${API_BASE}/contents/updates.xml', - data=payload, method='PUT', - headers={ - 'Authorization': 'token ${GA_TOKEN}', - 'Content-Type': 'application/json' - }) - try: - urllib.request.urlopen(req) - print('updates.xml synced to main') - except Exception as e: - print(f'ERROR: failed to sync updates.xml to main: {e}', file=sys.stderr) - sys.exit(1) - " \ - && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \ - || echo "::error::failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY - else - echo "::error::could not get updates.xml SHA from main — file may not exist on main yet" >> $GITHUB_STEP_SUMMARY - 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 }}" - REPO="${{ github.repository }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - - PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_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 /tmp/moko-platform/cli/platform_detect.php --path . 2>/dev/null || true) - if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/moko-platform/deploy/deploy-joomla.php" ]; then - php /tmp/moko-platform/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json - elif [ -f "/tmp/moko-platform/deploy/deploy-sftp.php" ]; then - php /tmp/moko-platform/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: Validate updates.xml integrity - run: | - ERRORS=0 - - if [ ! -f "updates.xml" ]; then - echo "::error::updates.xml not found" - exit 1 - fi - - # Well-formed XML - if ! python3 -c "import xml.etree.ElementTree as ET; ET.parse('updates.xml')" 2>/dev/null; then - echo "::error::updates.xml is not valid XML" - ERRORS=$((ERRORS+1)) - fi - - python3 << 'PYEOF' - import xml.etree.ElementTree as ET, sys, re, os - - tree = ET.parse("updates.xml") - root = tree.getroot() - updates = root.findall("update") - errors = 0 - warnings = 0 - seen_tags = set() - - # All 5 channels MUST be present - REQUIRED_CHANNELS = {"stable", "rc", "beta", "alpha", "dev"} - VALID_TAGS = REQUIRED_CHANNELS | {"development"} # accept legacy alias - REPO = os.environ.get("GITEA_REPO", "") - ORG = os.environ.get("GITEA_ORG", "MokoConsulting") - REPO_BASE = f"https://git.mokoconsulting.tech/{ORG}/" - - # Gitea release tag names per channel (Moko standard) - RELEASE_TAG_MAP = { - "stable": "stable", - "rc": "release-candidate", - "beta": "beta", - "alpha": "alpha", - "dev": "development", - "development": "development", - } - - # Joomla update XML required fields per - # https://docs.joomla.org/Deploying_an_Update_Server - REQUIRED_FIELDS = ["name", "element", "type", "version", "infourl"] - - for i, u in enumerate(updates): - tag_el = u.find("tags/tag") - tag = tag_el.text.strip() if tag_el is not None and tag_el.text else None - label = f"Entry {i+1} ({tag or '?'})" - - # -- Required Joomla fields -- - for field in REQUIRED_FIELDS: - el = u.find(field) - if el is None or not (el.text or "").strip(): - print(f"::error::{label}: missing required <{field}>") - errors += 1 - - # -- -- - dl = u.find("downloads/downloadurl") - if dl is None or not (dl.text or "").strip(): - print(f"::error::{label}: missing ") - errors += 1 - else: - dl_url = dl.text.strip() - # Must point to org repo - if REPO_BASE not in dl_url: - print(f"::error::{label}: download URL not under {REPO_BASE}: {dl_url}") - errors += 1 - # Must end in .zip - if not dl_url.endswith(".zip"): - print(f"::error::{label}: download URL must end in .zip: {dl_url}") - errors += 1 - # Must use correct Gitea release tag in path - if tag and tag in RELEASE_TAG_MAP: - expected_tag = RELEASE_TAG_MAP[tag] - if f"/download/{expected_tag}/" not in dl_url: - print(f"::error::{label}: download URL should contain /download/{expected_tag}/ but got: {dl_url}") - errors += 1 - - # -- (required for Joomla to match update) -- - client = u.find("client") - if client is None or not (client.text or "").strip(): - print(f"::error::{label}: missing (required for Joomla update matching)") - errors += 1 - - # -- -- - tp = u.find("targetplatform") - if tp is None: - print(f"::error::{label}: missing ") - errors += 1 - else: - tp_name = tp.get("name", "") - tp_ver = tp.get("version", "") - if tp_name != "joomla": - print(f"::error::{label}: targetplatform name should be 'joomla', got '{tp_name}'") - errors += 1 - if not tp_ver: - print(f"::error::{label}: targetplatform missing version regex") - errors += 1 - elif "5" not in tp_ver or "6" not in tp_ver: - print(f"::warning::{label}: targetplatform version may not cover Joomla 5+6: {tp_ver}") - warnings += 1 - - # -- must be valid Joomla type -- - type_el = u.find("type") - if type_el is not None and type_el.text: - valid_types = {"component", "module", "plugin", "template", "library", "package", "file"} - if type_el.text.strip() not in valid_types: - print(f"::error::{label}: invalid type '{type_el.text}' (expected: {valid_types})") - errors += 1 - - # -- format (XX.YY.ZZ with optional suffix) -- - ver_el = u.find("version") - if ver_el is not None and ver_el.text: - if not re.match(r"^\d{2}\.\d{2}\.\d{2}(-\w+)?$", ver_el.text.strip()): - print(f"::warning::{label}: version '{ver_el.text}' does not match XX.YY.ZZ format") - warnings += 1 - - # -- and -- - for field in ["maintainer", "maintainerurl"]: - el = u.find(field) - if el is None or not (el.text or "").strip(): - print(f"::warning::{label}: missing <{field}>") - warnings += 1 - - # -- Valid stability tag -- - if tag is None: - print(f"::error::{label}: missing ") - errors += 1 - elif tag not in VALID_TAGS: - print(f"::error::{label}: invalid tag '{tag}' (expected: {VALID_TAGS})") - errors += 1 - - # -- Duplicate tag check -- - norm_tag = "dev" if tag == "development" else tag - if norm_tag in seen_tags: - print(f"::error::{label}: duplicate channel '{tag}'") - errors += 1 - if norm_tag: - seen_tags.add(norm_tag) - - # -- All 5 channels must exist -- - missing = REQUIRED_CHANNELS - seen_tags - if missing: - print(f"::error::Missing required update channels: {', '.join(sorted(missing))}") - errors += 1 - - # -- Version ordering: higher stability must not exceed dev version -- - channel_versions = {} - for u in updates: - tag_el = u.find("tags/tag") - ver_el = u.find("version") - if tag_el is not None and ver_el is not None and tag_el.text and ver_el.text: - norm = "dev" if tag_el.text.strip() == "development" else tag_el.text.strip() - # Strip suffix for comparison (01.00.18-dev -> 01.00.18) - base_ver = re.sub(r"-\w+$", "", ver_el.text.strip()) - channel_versions[norm] = base_ver - - # Cascade check: dev >= alpha >= beta >= rc >= stable - ORDER = ["dev", "alpha", "beta", "rc", "stable"] - for j in range(1, len(ORDER)): - current = ORDER[j] - previous = ORDER[j - 1] - if current in channel_versions and previous in channel_versions: - if channel_versions[current] > channel_versions[previous]: - print(f"::error::{current} version ({channel_versions[current]}) is ahead of {previous} ({channel_versions[previous]})") - errors += 1 - - # -- Summary -- - print(f"\nupdates.xml validation: {len(updates)} entries, {errors} error(s), {warnings} warning(s)") - if errors > 0: - sys.exit(1) - PYEOF - - - name: Summary - if: always() - run: | - echo "## Joomla 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_VERSION}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index cb41f01..b4b7b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,196 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- **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 `onMokoJoomCrossGetServices` 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 + ### 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 +- **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 `#__mokojoomcross_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 + +### Fixed +- **Content plugin**: Fixed `onContentBeforeDisplay` signature for Joomla 5/6 — now accepts `BeforeDisplayEvent` object instead of individual parameters + +### Added +- **CrossPostDispatcher**: New static helper (`com_mokojoomcross/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_mokojoomcross_events**: New source plugin for MokoJoomCalendar — cross-posts calendar events when published +- **plg_system_mokojoomcross_gallery**: New source plugin for MokoJoomGallery — cross-posts galleries and images when published + +### Fixed +- **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 `` 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 +- **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/mokojoomcross/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 `mokojoomcross` 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 +- `MokoJoomCrossServiceInterface` 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_mokojoomcross`) — 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 MokoWaaS 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 @MokoWaaSBot + custom bot, HTML/Markdown, 4096 chars +- Discord — Webhooks, default MokoWaaS webhook mode, embeds, 2000 chars +- Slack — Incoming Webhooks, default MokoWaaS 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.00.00] - 2026-05-28 + +### Added +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c0b4858 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ +# Contributing to Moko Consulting Projects + +Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. + +## Branching Workflow + +``` +feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main +``` + +### Step by step + +1. **Create a feature branch** from `dev`: + ```bash + git checkout dev && git pull + git checkout -b feature/my-change + ``` + +2. **Work and commit** on your feature branch. Push to origin. + +3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. + +4. **When ready for release**, open a **draft PR**: `dev` → `main`. + - This automatically renames the source branch to `rc` (release candidate) + - An RC pre-release is built and uploaded + +5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: + - Rename `dev` to `alpha` for early testing → alpha pre-release is built + - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built + - When the draft PR is created, the branch is renamed to `rc` + +6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. + +7. **Merging to main** triggers the stable release pipeline: + - Minor version bump (e.g., `02.09.xx` → `02.10.00`) + - Stability suffix stripped (clean version) + - Gitea release created with ZIP/tar.gz packages + - `updates.xml` updated (Joomla extensions) + - `dev` branch recreated from `main` + +### Branch summary + +| Branch | Purpose | Created by | +|--------|---------|-----------| +| `feature/*` | New features and fixes | Developer | +| `dev` | Integration branch | Auto-recreated after release | +| `alpha` | Alpha pre-release testing | Manual rename from `dev` | +| `beta` | Beta pre-release testing | Manual rename from `alpha` | +| `rc` | Release candidate | Auto-renamed on draft PR to main | +| `main` | Stable releases | Protected, merge only | +| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | + +### Protected branches + +| Branch | Direct push | Merge via | +|--------|------------|-----------| +| `main` | Blocked (CI bot whitelisted) | PR merge only | +| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | +| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | +| `alpha` | Blocked (CI bot whitelisted) | Manual rename | +| `beta` | Blocked (CI bot whitelisted) | Manual rename | +| `feature/*` | Open | N/A (source branch) | + +## Version Policy + +### Format + +All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: + +- **XX** — Major version (breaking changes) +- **YY** — Minor version (new features, bumped on release to main) +- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) + +Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. + +### Stability suffixes + +Each branch appends a suffix to indicate stability: + +| Branch | Suffix | Example | +|--------|--------|---------| +| `main` | (none) | `02.09.00` | +| `dev` | `-dev` | `02.09.01-dev` | +| `feature/*` | `-dev` | `02.09.01-dev` | +| `alpha` | `-alpha` | `02.09.01-alpha` | +| `beta` | `-beta` | `02.09.01-beta` | +| `rc` | `-rc` | `02.09.01-rc` | + +### Auto version bump + +On every push to `dev`, `feature/*`, or `patch/*`: + +1. Patch version incremented +2. Stability suffix `-dev` applied +3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) +4. Commit created with `[skip ci]` to avoid loops + +### Release version flow + +Version bumps happen at specific release events: + +| Event | Bump | Example | +|-------|------|---------| +| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | +| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | +| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | +| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | + +### Release stream copies + +When a higher-stability release is published, copies are created for all lesser streams with the same base version: + +- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` +- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` + +This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). + +### Version files + +The version tools update all files containing version stamps: + +- `.mokogitea/manifest.xml` (canonical source) +- Joomla XML manifests (`` tag) +- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) +- `package.json`, `pyproject.toml` +- Any text file with a `VERSION: XX.YY.ZZ` label + +Files synced from other repos (with a `# REPO:` header) are not touched. + +## Code Standards + +- **PHP**: PSR-12, tabs for indentation +- **Copyright**: all files must include the Moko Consulting copyright header +- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) +- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names + +## Commit Messages + +Use conventional commit format: + +``` +type(scope): short description + +Optional body with context. + +Authored-by: Moko Consulting +``` + +Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` + +Special flags in commit messages: +- `[skip ci]` — skip all CI workflows +- `[skip bump]` — skip auto version bump only + +## Reporting Issues + +Use the repository's issue tracker with the appropriate template. + +--- + +*Moko Consulting * diff --git a/README.md b/README.md index 851ecdc..7be71dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoJoomCross - + Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6. diff --git a/automation/ci-issue-reporter.sh b/automation/ci-issue-reporter.sh new file mode 100644 index 0000000..65c47ba --- /dev/null +++ b/automation/ci-issue-reporter.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# ============================================================================ +# Copyright (C) 2026 Moko Consulting +# +# 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 </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 </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 diff --git a/src/packages/com_mokojoomcross/config.xml b/src/packages/com_mokojoomcross/config.xml index fa5dfa3..fdb5824 100644 --- a/src/packages/com_mokojoomcross/config.xml +++ b/src/packages/com_mokojoomcross/config.xml @@ -12,6 +12,18 @@ + + + + + + +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ +
diff --git a/src/packages/com_mokojoomcross/forms/filter_logs.xml b/src/packages/com_mokojoomcross/forms/filter_logs.xml new file mode 100644 index 0000000..44f9cdb --- /dev/null +++ b/src/packages/com_mokojoomcross/forms/filter_logs.xml @@ -0,0 +1,37 @@ + +
+ + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokojoomcross/forms/filter_posts.xml b/src/packages/com_mokojoomcross/forms/filter_posts.xml index 0e70b49..7fddb0f 100644 --- a/src/packages/com_mokojoomcross/forms/filter_posts.xml +++ b/src/packages/com_mokojoomcross/forms/filter_posts.xml @@ -20,6 +20,19 @@
+ + + + diff --git a/src/packages/com_mokojoomcross/forms/filter_templates.xml b/src/packages/com_mokojoomcross/forms/filter_templates.xml new file mode 100644 index 0000000..4842ac8 --- /dev/null +++ b/src/packages/com_mokojoomcross/forms/filter_templates.xml @@ -0,0 +1,51 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokojoomcross/forms/post.xml b/src/packages/com_mokojoomcross/forms/post.xml new file mode 100644 index 0000000..befc26d --- /dev/null +++ b/src/packages/com_mokojoomcross/forms/post.xml @@ -0,0 +1,125 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+
diff --git a/src/packages/com_mokojoomcross/forms/service.xml b/src/packages/com_mokojoomcross/forms/service.xml index 55c46f3..9448759 100644 --- a/src/packages/com_mokojoomcross/forms/service.xml +++ b/src/packages/com_mokojoomcross/forms/service.xml @@ -28,15 +28,46 @@ required="true" default=""> + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokojoomcross/forms/template.xml b/src/packages/com_mokojoomcross/forms/template.xml new file mode 100644 index 0000000..2ccb62a --- /dev/null +++ b/src/packages/com_mokojoomcross/forms/template.xml @@ -0,0 +1,61 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini index 548df81..dff41e6 100644 --- a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini +++ b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini @@ -58,3 +58,456 @@ COM_MOKOJOOMCROSS_CONFIG_LOG_RETENTION="Log Retention (days)" COM_MOKOJOOMCROSS_CONFIG_LOG_RETENTION_DESC="Number of days to keep activity logs" COM_MOKOJOOMCROSS_CONFIG_DEFAULT_TEMPLATE="Default Message Template" COM_MOKOJOOMCROSS_CONFIG_DEFAULT_TEMPLATE_DESC="Default template for cross-posts. Placeholders: {title}, {url}, {introtext}, {image}, {category}, {author}" + +; Table headings +COM_MOKOJOOMCROSS_HEADING_STATUS="Status" +COM_MOKOJOOMCROSS_HEADING_ARTICLE="Article" +COM_MOKOJOOMCROSS_HEADING_SERVICE="Service" +COM_MOKOJOOMCROSS_HEADING_MESSAGE="Message" +COM_MOKOJOOMCROSS_HEADING_POSTED_AT="Posted" +COM_MOKOJOOMCROSS_HEADING_CREATED="Created" +COM_MOKOJOOMCROSS_HEADING_LEVEL="Level" +COM_MOKOJOOMCROSS_HEADING_MODE="Mode" + +; Dashboard +COM_MOKOJOOMCROSS_DASHBOARD_RECENT_ACTIVITY="Recent Activity" +COM_MOKOJOOMCROSS_DASHBOARD_NO_RECENT="No recent activity." +COM_MOKOJOOMCROSS_DASHBOARD_TOTAL_POSTS="Total Posts" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING_TITLE="Page-load queue processing is active" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING="You are using page-load processing for the cross-post queue. This is a fallback method and may be unreliable on low-traffic sites. For production use, switch to Joomla Scheduled Tasks: create a task of type MokoJoomCross - Process Queue in System → Scheduled Tasks, then set queue processing to Scheduler only in component options." + +; Evergreen Configuration +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN="Evergreen Re-sharing" +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_ENABLED="Enable Evergreen" +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_ENABLED_DESC="Allow articles marked as evergreen to be automatically re-shared on a recurring schedule." +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_DEFAULT_INTERVAL="Default Interval (days)" +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_DEFAULT_INTERVAL_DESC="Default number of days between re-shares when no per-article interval is set." +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_MAX_PER_RUN="Max Re-shares Per Run" +COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_MAX_PER_RUN_DESC="Maximum number of evergreen articles to re-share in a single queue processing run. Prevents flooding platforms." + +; Queue Processing Configuration +COM_MOKOJOOMCROSS_CONFIG_QUEUE="Queue Processing" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING="Processing Method" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING_DESC="How queued posts, retries, and scheduled posts are processed. Scheduler (recommended) uses Joomla's built-in Task Scheduler. Page-load piggybacks on page requests." +COM_MOKOJOOMCROSS_CONFIG_QUEUE_SCHEDULER="Scheduler only (recommended)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PAGELOAD="Page-load only (fallback)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_BOTH="Both (scheduler + page-load)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT="Page-load Client" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT_DESC="Which Joomla application triggers page-load processing." +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_BOTH="Backend and Frontend" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_ADMIN="Backend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_SITE="Frontend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL="Page-load Interval (seconds)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL_DESC="Minimum seconds between page-load queue runs. Lower = more responsive but more DB queries per page load." + +; Submenu (extended) +COM_MOKOJOOMCROSS_SUBMENU_TEMPLATES="Templates" + +; Template Management +COM_MOKOJOOMCROSS_TEMPLATE_BODY="Template Body" +COM_MOKOJOOMCROSS_TEMPLATE_BODY_DESC="Message template with placeholders. Use the reference panel on the right for available placeholders." +COM_MOKOJOOMCROSS_TEMPLATE_SERVICE_TYPE_DESC="Which platform this template is for. 'Default' is the fallback when no platform-specific template exists." +COM_MOKOJOOMCROSS_TEMPLATE_TYPE_DEFAULT="Default (fallback)" +COM_MOKOJOOMCROSS_TEMPLATE_PREVIEW="Preview" +COM_MOKOJOOMCROSS_TEMPLATE_PLACEHOLDERS="Available Placeholders" + +; Placeholders +COM_MOKOJOOMCROSS_PLACEHOLDER_TITLE="Article title" +COM_MOKOJOOMCROSS_PLACEHOLDER_URL="Article URL" +COM_MOKOJOOMCROSS_PLACEHOLDER_INTROTEXT="Intro text (280 chars, no HTML)" +COM_MOKOJOOMCROSS_PLACEHOLDER_FULLTEXT="Full text (500 chars, no HTML)" +COM_MOKOJOOMCROSS_PLACEHOLDER_IMAGE="Intro image URL" +COM_MOKOJOOMCROSS_PLACEHOLDER_CATEGORY="Category name" +COM_MOKOJOOMCROSS_PLACEHOLDER_AUTHOR="Author name" +COM_MOKOJOOMCROSS_PLACEHOLDER_DATE="Publish date (YYYY-MM-DD)" + +; Logs +COM_MOKOJOOMCROSS_FILTER_LEVEL="Level" +COM_MOKOJOOMCROSS_SELECT_LEVEL="- Select Level -" +COM_MOKOJOOMCROSS_LEVEL_ASC="Level ascending" +COM_MOKOJOOMCROSS_LEVEL_DESC="Level descending" + +; Analytics Dashboard +COM_MOKOJOOMCROSS_DASHBOARD_SERVICE_BREAKDOWN="Posts by Service" +COM_MOKOJOOMCROSS_DASHBOARD_TOP_ARTICLES="Most Cross-Posted Articles" +COM_MOKOJOOMCROSS_DASHBOARD_SUCCESS_RATE="Success Rate" + +; OAuth +COM_MOKOJOOMCROSS_OAUTH_NO_SERVICE="No service specified for OAuth authorization." +COM_MOKOJOOMCROSS_OAUTH_SERVICE_NOT_FOUND="Service not found." +COM_MOKOJOOMCROSS_OAUTH_NO_CLIENT_ID="No OAuth Client ID configured for %s. Set it in Extensions → Plugins → MokoJoomCross - %s." +COM_MOKOJOOMCROSS_OAUTH_NOT_SUPPORTED="OAuth is not supported for %s." +COM_MOKOJOOMCROSS_OAUTH_PLATFORM_ERROR="Platform returned error: %s" +COM_MOKOJOOMCROSS_OAUTH_INVALID_CALLBACK="Invalid OAuth callback — missing code or state." +COM_MOKOJOOMCROSS_OAUTH_INVALID_STATE="Invalid OAuth state parameter." +COM_MOKOJOOMCROSS_OAUTH_TOKEN_ERROR="Token exchange failed: %s" +COM_MOKOJOOMCROSS_OAUTH_SUCCESS="%s connected successfully! Access token stored." + +; Post edit +COM_MOKOJOOMCROSS_NEW_POST="New Post" +COM_MOKOJOOMCROSS_EDIT_POST="Edit Post" +COM_MOKOJOOMCROSS_POST_ARTICLE="Article" +COM_MOKOJOOMCROSS_POST_ARTICLE_DESC="The Joomla article to cross-post." +COM_MOKOJOOMCROSS_SELECT_ARTICLE="- Select Article -" +COM_MOKOJOOMCROSS_POST_SERVICE="Service" +COM_MOKOJOOMCROSS_POST_SERVICE_DESC="The service to post to." +COM_MOKOJOOMCROSS_SELECT_SERVICE="- Select Service -" +COM_MOKOJOOMCROSS_POST_MESSAGE="Message" +COM_MOKOJOOMCROSS_POST_MESSAGE_DESC="The message to send to the platform. Use template placeholders or write a custom message." +COM_MOKOJOOMCROSS_POST_STATUS="Status" +COM_MOKOJOOMCROSS_STATUS_QUEUED="Queued" +COM_MOKOJOOMCROSS_STATUS_SCHEDULED="Scheduled" +COM_MOKOJOOMCROSS_STATUS_POSTED="Posted" +COM_MOKOJOOMCROSS_STATUS_FAILED="Failed" +COM_MOKOJOOMCROSS_POST_SCHEDULED_AT="Scheduled Date/Time" +COM_MOKOJOOMCROSS_POST_SCHEDULED_AT_DESC="When to send this post. Leave empty to process immediately. Set a future date to schedule." +COM_MOKOJOOMCROSS_POST_RESULTS="Post Results" +COM_MOKOJOOMCROSS_POST_PLATFORM_ID="Platform Post ID" +COM_MOKOJOOMCROSS_POST_ERROR="Error Message" +COM_MOKOJOOMCROSS_POST_RETRY_COUNT="Retry Count" +COM_MOKOJOOMCROSS_POST_POSTED_AT="Posted At" +COM_MOKOJOOMCROSS_POST_CREATE_HELP="Create a manual cross-post. Select an article and service, write your message, and optionally set a scheduled date. Leave the schedule empty to queue for immediate processing." +COM_MOKOJOOMCROSS_POST_REQUEUE="Re-queue for Posting" +COM_MOKOJOOMCROSS_POST_REQUEUE_HELP="Reset this post to queued status so it will be processed again on the next queue run." + +; Service edit +COM_MOKOJOOMCROSS_NEW_SERVICE="New Service" +COM_MOKOJOOMCROSS_EDIT_SERVICE="Edit Service" +COM_MOKOJOOMCROSS_SERVICE_DETAILS="Service Details" +COM_MOKOJOOMCROSS_CREDENTIALS_HELP="Fill in the connection details for the selected platform. Fields change based on the service type you choose above." + +; Credential mode +COM_MOKOJOOMCROSS_FIELD_CRED_MODE="Connection Mode" +COM_MOKOJOOMCROSS_FIELD_CRED_MODE_DESC="Default uses the pre-configured MokoWaaS account. Custom lets you use your own API credentials." +COM_MOKOJOOMCROSS_CRED_MODE_DEFAULT="Default (MokoWaaS)" +COM_MOKOJOOMCROSS_CRED_MODE_CUSTOM="Custom (your own credentials)" + +; Telegram +COM_MOKOJOOMCROSS_CRED_TELEGRAM_CHAT_ID="Chat ID" +COM_MOKOJOOMCROSS_CRED_TELEGRAM_CHAT_ID_DESC="Telegram channel, group, or user chat ID. Channel IDs start with -100. Get yours from @userinfobot." +COM_MOKOJOOMCROSS_CRED_TELEGRAM_BOT_TOKEN="Bot Token" +COM_MOKOJOOMCROSS_CRED_TELEGRAM_BOT_TOKEN_DESC="Your custom Telegram bot token from @BotFather. Only needed in Custom mode." + +; Discord +COM_MOKOJOOMCROSS_CRED_DISCORD_WEBHOOK="Webhook URL" +COM_MOKOJOOMCROSS_CRED_DISCORD_WEBHOOK_DESC="Discord channel webhook URL. Create one in Channel Settings → Integrations → Webhooks." + +; Slack +COM_MOKOJOOMCROSS_CRED_SLACK_WEBHOOK="Webhook URL" +COM_MOKOJOOMCROSS_CRED_SLACK_WEBHOOK_DESC="Slack Incoming Webhook URL. Create one at api.slack.com/apps." + +; Teams +COM_MOKOJOOMCROSS_CRED_TEAMS_WEBHOOK="Webhook URL" +COM_MOKOJOOMCROSS_CRED_TEAMS_WEBHOOK_DESC="Microsoft Teams Incoming Webhook URL. Create in channel Connectors." + +; Google Chat +COM_MOKOJOOMCROSS_CRED_GOOGLECHAT_WEBHOOK="Webhook URL" +COM_MOKOJOOMCROSS_CRED_GOOGLECHAT_WEBHOOK_DESC="Google Chat space webhook URL." + +; Facebook +COM_MOKOJOOMCROSS_CRED_FACEBOOK_PAGE_ID="Facebook Page ID" +COM_MOKOJOOMCROSS_CRED_FACEBOOK_PAGE_ID_DESC="Your Facebook Page numeric ID. Find it in Page Settings → About." +COM_MOKOJOOMCROSS_CRED_FACEBOOK_TOKEN="Page Access Token" +COM_MOKOJOOMCROSS_CRED_FACEBOOK_TOKEN_DESC="Long-lived Page Access Token. Use the Authorize button below or generate via Meta Business Suite." + +; Threads +COM_MOKOJOOMCROSS_CRED_THREADS_USER_ID="Threads User ID" +COM_MOKOJOOMCROSS_CRED_THREADS_TOKEN="Access Token" + +; Twitter (OAuth 1.0a) +COM_MOKOJOOMCROSS_CRED_TWITTER_API_KEY="API Key (Consumer Key)" +COM_MOKOJOOMCROSS_CRED_TWITTER_API_KEY_DESC="Consumer Key from the Twitter Developer Portal → Keys and Tokens." +COM_MOKOJOOMCROSS_CRED_TWITTER_API_SECRET="API Secret (Consumer Secret)" +COM_MOKOJOOMCROSS_CRED_TWITTER_API_SECRET_DESC="Consumer Secret from the Twitter Developer Portal → Keys and Tokens." +COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_DESC="User access token from the Developer Portal → Keys and Tokens → Authentication Tokens." +COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_SECRET="Access Token Secret" +COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_SECRET_DESC="User access token secret from the Developer Portal → Keys and Tokens → Authentication Tokens." + +; LinkedIn +COM_MOKOJOOMCROSS_CRED_LINKEDIN_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_LINKEDIN_ORG_ID="Organization ID" +COM_MOKOJOOMCROSS_CRED_LINKEDIN_ORG_ID_DESC="LinkedIn Company Page ID. Leave empty to post as yourself." + +; Mastodon +COM_MOKOJOOMCROSS_CRED_MASTODON_INSTANCE="Instance URL" +COM_MOKOJOOMCROSS_CRED_MASTODON_INSTANCE_DESC="Your Mastodon server (e.g. https://mastodon.social)" +COM_MOKOJOOMCROSS_CRED_MASTODON_TOKEN="Access Token" + +; Bluesky +COM_MOKOJOOMCROSS_CRED_BLUESKY_HANDLE="Handle" +COM_MOKOJOOMCROSS_CRED_BLUESKY_HANDLE_DESC="Your Bluesky handle (e.g. user.bsky.social)" +COM_MOKOJOOMCROSS_CRED_BLUESKY_APP_PWD="App Password" +COM_MOKOJOOMCROSS_CRED_BLUESKY_APP_PWD_DESC="Generate in Bluesky Settings → Advanced → App Passwords." + +; WhatsApp +COM_MOKOJOOMCROSS_CRED_WHATSAPP_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_WHATSAPP_PHONE_ID="Phone Number ID" +COM_MOKOJOOMCROSS_CRED_WHATSAPP_RECIPIENT="Recipient Number" +COM_MOKOJOOMCROSS_CRED_WHATSAPP_RECIPIENT_DESC="Phone number to send to, with country code (e.g. +1234567890)" + +; Mailchimp +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_KEY="API Key" +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_KEY_DESC="Mailchimp API key (ends with -us1, -us2, etc.)" +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_LIST="Audience/List ID" +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_LIST_DESC="The audience to send campaigns to. Find in Audience → Settings → Audience ID." + +; SendGrid +COM_MOKOJOOMCROSS_CRED_SENDGRID_KEY="API Key" +COM_MOKOJOOMCROSS_CRED_SENDGRID_LIST="Contact List ID" + +; Webhook +COM_MOKOJOOMCROSS_CRED_WEBHOOK_URL="Webhook URL" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_URL_DESC="The URL to send article data to. Works with Zapier, IFTTT, n8n, Make, or any custom endpoint." +COM_MOKOJOOMCROSS_CRED_WEBHOOK_METHOD="HTTP Method" + +; Matrix +COM_MOKOJOOMCROSS_CRED_MATRIX_HOMESERVER="Homeserver URL" +COM_MOKOJOOMCROSS_CRED_MATRIX_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_MATRIX_ROOM="Room ID" +COM_MOKOJOOMCROSS_CRED_MATRIX_ROOM_DESC="Matrix room ID (e.g. !abc123:matrix.org)" + +; Ntfy +COM_MOKOJOOMCROSS_CRED_NTFY_SERVER="Server URL" +COM_MOKOJOOMCROSS_CRED_NTFY_TOPIC="Topic Name" +COM_MOKOJOOMCROSS_CRED_NTFY_TOPIC_DESC="The notification topic (e.g. my-site-updates). Subscribers use this to receive push notifications." +COM_MOKOJOOMCROSS_CRED_NTFY_TOKEN="Auth Token" +COM_MOKOJOOMCROSS_CRED_NTFY_TOKEN_DESC="Optional authentication token if your ntfy server requires it." + +; WordPress +COM_MOKOJOOMCROSS_CRED_WP_SITE="WordPress Site URL" +COM_MOKOJOOMCROSS_CRED_WP_USER="Username" +COM_MOKOJOOMCROSS_CRED_WP_APP_PWD="Application Password" +COM_MOKOJOOMCROSS_CRED_WP_APP_PWD_DESC="Generate in WordPress → Users → Profile → Application Passwords." + +; Medium +COM_MOKOJOOMCROSS_CRED_MEDIUM_TOKEN="Integration Token" + +; Dev.to +COM_MOKOJOOMCROSS_CRED_DEVTO_KEY="API Key" + +; Ghost +COM_MOKOJOOMCROSS_CRED_GHOST_SITE="Ghost Site URL" +COM_MOKOJOOMCROSS_CRED_GHOST_KEY="Admin API Key" + +; Reddit +COM_MOKOJOOMCROSS_CRED_REDDIT_CLIENT_ID="App Client ID" +COM_MOKOJOOMCROSS_CRED_REDDIT_SECRET="App Secret" +COM_MOKOJOOMCROSS_CRED_REDDIT_USER="Reddit Username" +COM_MOKOJOOMCROSS_CRED_REDDIT_SUBREDDIT="Subreddit" +COM_MOKOJOOMCROSS_CRED_REDDIT_SUBREDDIT_DESC="Subreddit to post to (without r/ prefix)" + +; Authorize / OAuth +COM_MOKOJOOMCROSS_AUTHORIZE_BUTTON="Connect to %s" +COM_MOKOJOOMCROSS_AUTHORIZE_HELP="Click to open the authorization page. You'll be redirected back after granting access. Your token will be saved automatically." +COM_MOKOJOOMCROSS_OAUTH_HELP_TITLE="Authorization Required" +COM_MOKOJOOMCROSS_OAUTH_HELP_BODY="This service requires OAuth authorization. Save the service first, then click the Connect button below to authorize access." + +; LinkedIn (additional) +COM_MOKOJOOMCROSS_CRED_LINKEDIN_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMCROSS_CRED_LINKEDIN_REFRESH_TOKEN_DESC="OAuth refresh token for automatic access token renewal." + +; Bluesky (additional) +COM_MOKOJOOMCROSS_CRED_BLUESKY_PDS_URL="PDS URL" +COM_MOKOJOOMCROSS_CRED_BLUESKY_PDS_URL_DESC="Personal Data Server URL. Default is https://bsky.social. Only change for self-hosted PDS." + +; Discord (additional) +COM_MOKOJOOMCROSS_CRED_DISCORD_USERNAME="Display Name Override" +COM_MOKOJOOMCROSS_CRED_DISCORD_USERNAME_DESC="Override the webhook's default display name. Leave empty to use the webhook name." +COM_MOKOJOOMCROSS_CRED_DISCORD_AVATAR="Avatar URL Override" +COM_MOKOJOOMCROSS_CRED_DISCORD_AVATAR_DESC="Override the webhook's default avatar with a custom image URL." + +; Mailchimp (additional) +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_NAME="From Name" +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_NAME_DESC="Sender name for campaigns. Leave empty to use the audience default." +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_EMAIL="From Email" +COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_EMAIL_DESC="Sender email for campaigns. Must be a verified sending domain." + +; SendGrid (additional) +COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_EMAIL="From Email" +COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_EMAIL_DESC="Verified sender email address for Single Sends." +COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_NAME="From Name" +COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_NAME_DESC="Display name for the sender." + +; Reddit (additional) +COM_MOKOJOOMCROSS_CRED_REDDIT_PASSWORD="Account Password" +COM_MOKOJOOMCROSS_CRED_REDDIT_PASSWORD_DESC="Required for Reddit script-type OAuth. The password for the Reddit account." + +; WordPress (additional) +COM_MOKOJOOMCROSS_CRED_WP_DEFAULT_STATUS="Default Post Status" +COM_MOKOJOOMCROSS_CRED_WP_DEFAULT_STATUS_DESC="Whether cross-posted articles appear as drafts or are published immediately." + +; Dev.to (additional) +COM_MOKOJOOMCROSS_CRED_DEVTO_ORG_ID="Organization ID" +COM_MOKOJOOMCROSS_CRED_DEVTO_ORG_ID_DESC="Optional. Publish under a Dev.to organization instead of your personal account." + +; Ghost (additional) +COM_MOKOJOOMCROSS_CRED_GHOST_DEFAULT_STATUS="Default Post Status" +COM_MOKOJOOMCROSS_CRED_GHOST_DEFAULT_STATUS_DESC="Whether cross-posted articles are saved as drafts or published immediately." + +; Status options (shared) +COM_MOKOJOOMCROSS_STATUS_DRAFT="Draft" +COM_MOKOJOOMCROSS_STATUS_PUBLISH="Publish" +COM_MOKOJOOMCROSS_STATUS_PUBLISHED="Published" + +; Pinterest +COM_MOKOJOOMCROSS_CRED_PINTEREST_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_PINTEREST_TOKEN_DESC="Pinterest API v5 access token from the Developer Portal." +COM_MOKOJOOMCROSS_CRED_PINTEREST_BOARD="Board ID" +COM_MOKOJOOMCROSS_CRED_PINTEREST_BOARD_DESC="The board to pin to. Find the ID in the board URL or via the API." + +; Tumblr +COM_MOKOJOOMCROSS_CRED_TUMBLR_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_TUMBLR_TOKEN_DESC="Tumblr OAuth access token." +COM_MOKOJOOMCROSS_CRED_TUMBLR_BLOG="Blog Name" +COM_MOKOJOOMCROSS_CRED_TUMBLR_BLOG_DESC="Your Tumblr blog name (e.g. myblog — without .tumblr.com)." + +; TikTok +COM_MOKOJOOMCROSS_CRED_TIKTOK_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_TIKTOK_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMCROSS_CRED_TIKTOK_OPEN_ID="Open ID" +COM_MOKOJOOMCROSS_CRED_TIKTOK_OPEN_ID_DESC="Your TikTok Open ID from the developer app authorization." + +; Nostr +COM_MOKOJOOMCROSS_CRED_NOSTR_PRIVKEY="Private Key" +COM_MOKOJOOMCROSS_CRED_NOSTR_PRIVKEY_DESC="Nostr private key in hex or nsec format. Used to sign events." +COM_MOKOJOOMCROSS_CRED_NOSTR_RELAYS="Relay URLs" +COM_MOKOJOOMCROSS_CRED_NOSTR_RELAYS_DESC="Comma-separated list of relay WebSocket URLs (e.g. wss://relay.damus.io, wss://nos.lol)." + +; ActivityPub +COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_INSTANCE="Instance URL" +COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_INSTANCE_DESC="Fediverse instance URL (Pleroma, Akkoma, Misskey, Pixelfed, etc.)." +COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_TOKEN_DESC="API access token from the instance's developer settings." + +; Brevo (Sendinblue) +COM_MOKOJOOMCROSS_CRED_BREVO_KEY="API Key" +COM_MOKOJOOMCROSS_CRED_BREVO_LIST="Contact List ID" +COM_MOKOJOOMCROSS_CRED_BREVO_LIST_DESC="Brevo contact list ID to send campaigns to." +COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_EMAIL="Sender Email" +COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_EMAIL_DESC="Must be a verified sender in your Brevo account." +COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_NAME="Sender Name" + +; ConvertKit +COM_MOKOJOOMCROSS_CRED_CONVERTKIT_KEY="API Key" +COM_MOKOJOOMCROSS_CRED_CONVERTKIT_SECRET="API Secret" + +; Constant Contact +COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_LISTS="Contact List IDs" +COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_LISTS_DESC="Comma-separated list IDs to include in the campaign." + +; Hashnode +COM_MOKOJOOMCROSS_CRED_HASHNODE_TOKEN="Personal Access Token" +COM_MOKOJOOMCROSS_CRED_HASHNODE_PUB_ID="Publication ID" +COM_MOKOJOOMCROSS_CRED_HASHNODE_PUB_ID_DESC="Your Hashnode publication ID. Find in Dashboard → General settings." + +; Google Blogger +COM_MOKOJOOMCROSS_CRED_BLOGGER_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_BLOGGER_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMCROSS_CRED_BLOGGER_BLOG_ID="Blog ID" +COM_MOKOJOOMCROSS_CRED_BLOGGER_BLOG_ID_DESC="Numeric Blog ID from Blogger settings or the Blogger API." + +; Google Business Profile +COM_MOKOJOOMCROSS_CRED_GBUSINESS_TOKEN="Access Token" +COM_MOKOJOOMCROSS_CRED_GBUSINESS_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMCROSS_CRED_GBUSINESS_LOCATION="Location ID" +COM_MOKOJOOMCROSS_CRED_GBUSINESS_LOCATION_DESC="Google Business location ID (e.g. locations/1234567890)." +COM_MOKOJOOMCROSS_CRED_GBUSINESS_ACCOUNT="Account ID" +COM_MOKOJOOMCROSS_CRED_GBUSINESS_ACCOUNT_DESC="Google Business account ID (e.g. accounts/1234567890)." + +; RSS Feed +COM_MOKOJOOMCROSS_CRED_RSSFEED_TITLE="Feed Title" +COM_MOKOJOOMCROSS_CRED_RSSFEED_TITLE_DESC="Title for the generated RSS feed. Defaults to the site name." +COM_MOKOJOOMCROSS_CRED_RSSFEED_MAX_ITEMS="Max Feed Items" +COM_MOKOJOOMCROSS_CRED_RSSFEED_MAX_ITEMS_DESC="Maximum number of items to include in the feed." + +; Webhook (additional) +COM_MOKOJOOMCROSS_CRED_WEBHOOK_AUTH_TYPE="Authentication" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_AUTH_TYPE_DESC="Authentication method for the webhook endpoint." +COM_MOKOJOOMCROSS_WEBHOOK_AUTH_NONE="None" +COM_MOKOJOOMCROSS_WEBHOOK_AUTH_BEARER="Bearer Token" +COM_MOKOJOOMCROSS_WEBHOOK_AUTH_BASIC="Basic Auth" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_BEARER_TOKEN="Bearer Token" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_BEARER_TOKEN_DESC="Authentication token sent as Authorization: Bearer {token}." +COM_MOKOJOOMCROSS_CRED_WEBHOOK_BASIC_USER="Username" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_BASIC_PWD="Password" +COM_MOKOJOOMCROSS_CRED_WEBHOOK_CONTENT_TYPE="Content Type" + +; Service help link +COM_MOKOJOOMCROSS_SERVICE_HELP_LINK="%s Setup Guide" + +; Setup help panel +COM_MOKOJOOMCROSS_SETUP_HELP_TITLE="How to set up" +COM_MOKOJOOMCROSS_SETUP_HELP_INTRO="Setting up a new service is easy:" +COM_MOKOJOOMCROSS_SETUP_STEP1="Choose a service type from the dropdown" +COM_MOKOJOOMCROSS_SETUP_STEP2="Fill in the connection details that appear" +COM_MOKOJOOMCROSS_SETUP_STEP3="For OAuth services, save first, then click Connect" +COM_MOKOJOOMCROSS_SETUP_STEP4="Set status to Published and save" + +; Test Connection +COM_MOKOJOOMCROSS_TEST_CONNECTION_TITLE="Test Connection" +COM_MOKOJOOMCROSS_TEST_CONNECTION_DESC="Verify that your credentials are valid and the service is reachable." +COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON="Test Connection" +COM_MOKOJOOMCROSS_TEST_CONNECTION_TESTING="Testing..." +COM_MOKOJOOMCROSS_TEST_CONNECTION_SUCCESS="Connection successful" +COM_MOKOJOOMCROSS_TEST_CONNECTION_FAILED="Connection failed" +COM_MOKOJOOMCROSS_TEST_CONNECTION_ERROR="Could not reach the server. Please try again." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE="No service specified for test." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND="Service record not found." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN="No service plugin available for type '%s'." + +; Bulk Queue Actions +COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED="Retry Failed" +COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED="Purge Posted" +COM_MOKOJOOMCROSS_POSTS_N_RETRIED="%d failed post(s) re-queued for retry." +COM_MOKOJOOMCROSS_POSTS_N_RETRIED_1="1 failed post re-queued for retry." +COM_MOKOJOOMCROSS_POSTS_N_PURGED="%d posted record(s) purged." +COM_MOKOJOOMCROSS_POSTS_N_PURGED_1="1 posted record purged." +COM_MOKOJOOMCROSS_POSTS_N_SCHEDULED="%d post(s) scheduled." +COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED="No posts selected." +COM_MOKOJOOMCROSS_SCHEDULE_NO_DATE="Please select a date and time for scheduling." +COM_MOKOJOOMCROSS_TOOLBAR_SCHEDULE="Schedule" +COM_MOKOJOOMCROSS_TOOLBAR_RETRY_SELECTED="Retry Selected" + +; Queue Depth Warning +COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog" +COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING="There are %d posts waiting in the queue. Please verify that the Joomla Task Scheduler is running and the MokoJoomCross scheduled task is enabled in System → Scheduled Tasks." + +; First-Publish-Only +COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY="First Publish Only" +COM_MOKOJOOMCROSS_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." + +; Trend Chart +COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART="Daily Post Trend" + +; Date Range Period Filter +COM_MOKOJOOMCROSS_PERIOD_7_DAYS="Last 7 days" +COM_MOKOJOOMCROSS_PERIOD_30_DAYS="Last 30 days" +COM_MOKOJOOMCROSS_PERIOD_90_DAYS="Last 90 days" +COM_MOKOJOOMCROSS_PERIOD_ALL_TIME="All time" + +; Hashtag Placeholders +COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS="Article tags (comma-separated)" +COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS="Article tags as hashtags (#Tag1 #Tag2)" +COM_MOKOJOOMCROSS_PLACEHOLDER_CUSTOM_FIELD="Custom field value (replace xxx with field name)" + +; CSV Export +COM_MOKOJOOMCROSS_EXPORT_CSV="Export CSV" + +; Service Stats (drill-down) +COM_MOKOJOOMCROSS_SERVICESTATS_RECENT_POSTS="Recent Posts" +COM_MOKOJOOMCROSS_SERVICESTATS_NO_POSTS="No posts for this service yet." +COM_MOKOJOOMCROSS_SERVICESTATS_TOP_ARTICLES="Top Articles for This Service" + +; API Dispatch +COM_MOKOJOOMCROSS_DISPATCH_MISSING_ARTICLE="Missing or invalid article_id in request body." +COM_MOKOJOOMCROSS_DISPATCH_INVALID_SERVICES="service_ids must be a non-empty array of service IDs." +COM_MOKOJOOMCROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found." +COM_MOKOJOOMCROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request." + +; Category Rules +COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES="Category Rules" +COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing" +COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokojoomcross_category_rules. A full admin UI will be added in a future release." diff --git a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.sys.ini b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.sys.ini index 2f8c6a4..9c85d9a 100644 --- a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.sys.ini +++ b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.sys.ini @@ -7,4 +7,5 @@ COM_MOKOJOOMCROSS_DESCRIPTION="Cross-posting Joomla content to social media, ema COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD="Dashboard" COM_MOKOJOOMCROSS_SUBMENU_POSTS="Post Queue" COM_MOKOJOOMCROSS_SUBMENU_SERVICES="Services" +COM_MOKOJOOMCROSS_SUBMENU_TEMPLATES="Templates" COM_MOKOJOOMCROSS_SUBMENU_LOGS="Activity Logs" diff --git a/src/packages/com_mokojoomcross/mokojoomcross.xml b/src/packages/com_mokojoomcross/mokojoomcross.xml index c9ff452..15d2015 100644 --- a/src/packages/com_mokojoomcross/mokojoomcross.xml +++ b/src/packages/com_mokojoomcross/mokojoomcross.xml @@ -1,7 +1,7 @@ com_mokojoomcross - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -48,6 +48,7 @@ COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD COM_MOKOJOOMCROSS_SUBMENU_POSTS COM_MOKOJOOMCROSS_SUBMENU_SERVICES + COM_MOKOJOOMCROSS_SUBMENU_TEMPLATES COM_MOKOJOOMCROSS_SUBMENU_LOGS diff --git a/src/packages/com_mokojoomcross/sql/install.mysql.sql b/src/packages/com_mokojoomcross/sql/install.mysql.sql index ae0ac86..44b86b8 100644 --- a/src/packages/com_mokojoomcross/sql/install.mysql.sql +++ b/src/packages/com_mokojoomcross/sql/install.mysql.sql @@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomcross_posts` ( `scheduled_at` datetime DEFAULT NULL COMMENT 'When to post (NULL = immediately)', `posted_at` datetime DEFAULT NULL COMMENT 'When actually posted', `retry_count` int(10) unsigned NOT NULL DEFAULT 0, - `error_message` text NOT NULL DEFAULT '', + `error_message` text NOT NULL, `created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', `modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`id`), @@ -76,4 +76,30 @@ INSERT INTO `#__mokojoomcross_templates` (`service_type`, `title`, `template_bod ('default', 'Default Template', '{title}\n\n{introtext}\n\n{url}', 1, 1, 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', '

{title}

\n

{introtext}

\n

Read more

', 1, 4, NOW()); +('mailchimp', 'Mailchimp Default', '

{title}

\n

{introtext}

\n

Read more

', 1, 4, NOW()), +('telegram', 'Telegram Default', '{title}\n\n{introtext}\n\nRead more', 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', '

{title}

\n

{introtext}

\n

Read more

', 1, 16, NOW()), +('brevo', 'Brevo Default', '

{title}

\n

{introtext}

\n

Read more

', 1, 17, NOW()), +('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()), +('reddit', 'Reddit Default', '{title}', 1, 19, NOW()), +('pinterest', 'Pinterest Default', '{title} - {introtext}', 1, 20, NOW()); + +CREATE TABLE IF NOT EXISTS `#__mokojoomcross_category_rules` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `category_id` int(10) unsigned NOT NULL, + `service_id` int(10) unsigned NOT NULL, + `published` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_category_service` (`category_id`, `service_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokojoomcross/sql/updates/mysql/01.01.00.sql b/src/packages/com_mokojoomcross/sql/updates/mysql/01.01.00.sql new file mode 100644 index 0000000..4e51ffb --- /dev/null +++ b/src/packages/com_mokojoomcross/sql/updates/mysql/01.01.00.sql @@ -0,0 +1,13 @@ +-- MokoJoomCross 01.01.00 — Category routing rules +-- Copyright (C) 2026 Moko Consulting. All rights reserved. +-- SPDX-License-Identifier: GPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS `#__mokojoomcross_category_rules` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `category_id` int(10) unsigned NOT NULL, + `service_id` int(10) unsigned NOT NULL, + `published` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_category_service` (`category_id`, `service_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokojoomcross/src/Controller/DashboardController.php b/src/packages/com_mokojoomcross/src/Controller/DashboardController.php new file mode 100644 index 0000000..66a6ac6 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Controller/DashboardController.php @@ -0,0 +1,59 @@ + + * @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\MokoJoomCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\MigrationHelper; + +class DashboardController extends BaseController +{ + /** + * Run Perfect Publisher Pro migration. + * + * @return void + */ + public function migrate(): void + { + // Check ACL + if (!$this->app->getIdentity()->authorise('mokojoomcross.migrate', 'com_mokojoomcross')) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false), + Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), + 'error' + ); + + return; + } + + $result = MigrationHelper::migrate(); + + if (!empty($result['errors'])) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false), + Text::sprintf('COM_MOKOJOOMCROSS_MIGRATION_ERROR', implode('; ', $result['errors'])), + 'error' + ); + + return; + } + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false), + Text::sprintf('COM_MOKOJOOMCROSS_MIGRATION_SUCCESS', $result['migrated'], $result['skipped']), + 'success' + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/DispatchController.php b/src/packages/com_mokojoomcross/src/Controller/DispatchController.php new file mode 100644 index 0000000..64f5277 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Controller/DispatchController.php @@ -0,0 +1,351 @@ + + * @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\MokoJoomCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; + +/** + * REST API controller for dispatching cross-posts. + * + * Endpoint: POST /api/index.php/v1/mokojoomcross/dispatch + * + * JSON body: + * { + * "article_id": 123, + * "service_ids": [1, 2, 3] // optional — omit to post to all enabled services + * } + * + * Returns JSON with the created post IDs and status. + * + * Authentication is handled by Joomla's API application (token or session). + * The webservices plugin routes POST requests here via the API router. + */ +class DispatchController extends BaseController +{ + /** + * Dispatch cross-posts for an article to one or more services. + * + * @return void + */ + public function dispatch(): void + { + $app = $this->app; + + // Enforce POST method — this is a state-changing action endpoint + if (strtoupper($this->input->getMethod()) !== 'POST') { + $this->sendJsonResponse(['error' => 'Method not allowed. Use POST.'], 405); + + return; + } + + // Read JSON body + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $articleId = (int) ($input['article_id'] ?? 0); + $serviceIds = $input['service_ids'] ?? null; + + if ($articleId < 1) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_MISSING_ARTICLE')], 400); + + return; + } + + // Validate service_ids if provided + if ($serviceIds !== null) { + if (!is_array($serviceIds) || empty($serviceIds)) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_INVALID_SERVICES')], 400); + + return; + } + + $serviceIds = array_map('intval', $serviceIds); + } + + $db = Factory::getDbo(); + + // Load the article + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . $articleId); + + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_ARTICLE_NOT_FOUND')], 404); + + return; + } + + // Load enabled services, optionally filtered by service_ids + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + if ($serviceIds !== null) { + $query->where($db->quoteName('id') . ' IN (' . implode(',', $serviceIds) . ')'); + } + + $db->setQuery($query); + $services = $db->loadObjectList() ?: []; + + if (empty($services)) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_NO_SERVICES')], 404); + + return; + } + + // Import service plugins and build type-to-plugin map. + // In Joomla 5+ with SubscriberInterface, plugins receive the Event object + // as their first argument. When they do $services[] = $this, they append to + // the Event via ArrayAccess at numeric indices starting at 1. + PluginHelper::importPlugin('mokojoomcross'); + + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]); + + try { + $app->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + // Read plugins back from the Event's ArrayAccess indices + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + $pluginMap = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoJoomCrossServiceInterface) { + $pluginMap[$plugin->getServiceType()] = $plugin; + } + } + + // Render template and create queue entries (same logic as system plugin dispatchCrossPost) + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $now = Factory::getDate()->toSql(); + $createdIds = []; + $skipped = []; + + // Build article URL + $articleUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $articleUrl .= '&catid=' . $article->catid; + } + + // Extract intro image for media + $media = []; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $media[] = Uri::root() . ltrim($images->image_intro, '/'); + } + + foreach ($services as $service) { + // Duplicate guard — skip if article already posted/queued for this service + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')'); + + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + $skipped[] = [ + 'service_id' => (int) $service->id, + 'service_type' => $service->service_type, + 'reason' => 'duplicate', + ]; + + continue; + } + + // Render template + $message = $this->renderTemplate($db, $article, $service, $componentParams); + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__mokojoomcross_posts', $post); + $postId = (int) $db->insertid(); + + $createdIds[] = [ + 'post_id' => $postId, + 'service_id' => (int) $service->id, + 'service_type' => $service->service_type, + 'status' => 'queued', + ]; + + // Write log entry + $log = (object) [ + 'post_id' => $postId, + 'service_id' => (int) $service->id, + 'level' => 'info', + 'message' => sprintf('API dispatch: queued article %d to %s', $article->id, $service->service_type), + 'context' => '{}', + 'created' => $now, + ]; + + $db->insertObject('#__mokojoomcross_logs', $log); + } + + $this->sendJsonResponse([ + 'article_id' => (int) $article->id, + 'dispatched' => $createdIds, + 'skipped' => $skipped, + ], 200); + } + + /** + * Render the message template for a service (simplified version of system plugin logic). + */ + private function renderTemplate($db, object $article, object $service, $componentParams): string + { + // Try service-specific template first, fall back to default + $query = $db->getQuery(true) + ->select($db->quoteName('template_body')) + ->from($db->quoteName('#__mokojoomcross_templates')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type) + . ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')') + ->order('CASE WHEN ' . $db->quoteName('service_type') . ' = ' + . $db->quote($service->service_type) . ' THEN 0 ELSE 1 END') + ->setLimit(1); + + $db->setQuery($query); + $template = $db->loadResult() ?: ($componentParams->get('default_template', "{title}\n\n{url}")); + + // Build article URL + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $url .= '&catid=' . $article->catid; + } + + // Resolve category name + $categoryName = ''; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($query); + $categoryName = $db->loadResult() ?: ''; + } + + // Resolve author name + $authorName = ''; + + if (!empty($article->created_by)) { + $query = $db->getQuery(true) + ->select($db->quoteName('name')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . (int) $article->created_by); + $db->setQuery($query); + $authorName = $db->loadResult() ?: ''; + } + + // Extract intro image + $introImage = ''; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $introImage = Uri::root() . ltrim($images->image_intro, '/'); + } + + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + + $replacements = [ + '{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, + ]; + + return str_replace(array_keys($replacements), array_values($replacements), $template); + } + + /** + * Send a JSON response and close the application. + * + * @param array $data Response data + * @param int $httpCode HTTP status code + * + * @return void + */ + private function sendJsonResponse(array $data, int $httpCode): void + { + $app = $this->app; + + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Status', (string) $httpCode); + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $app->close(); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/OauthController.php b/src/packages/com_mokojoomcross/src/Controller/OauthController.php new file mode 100644 index 0000000..884b556 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Controller/OauthController.php @@ -0,0 +1,196 @@ + + * @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\MokoJoomCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\OAuthHelper; + +/** + * OAuth controller for handling browser-based authorization flows. + * + * Endpoints: + * task=oauth.authorize — Initiate OAuth flow (redirect to platform) + * task=oauth.callback — Handle platform redirect with auth code + */ +class OauthController extends BaseController +{ + /** + * Initiate OAuth authorization for a service. + * + * Expects: service_id (int) in request + */ + public function authorize(): void + { + $serviceId = $this->input->getInt('service_id', 0); + + if (!$serviceId) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::_('COM_MOKOJOOMCROSS_OAUTH_NO_SERVICE'), + 'error' + ); + + return; + } + + $db = \Joomla\CMS\Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $service = $db->loadObject(); + + if (!$service) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::_('COM_MOKOJOOMCROSS_OAUTH_SERVICE_NOT_FOUND'), + 'error' + ); + + return; + } + + // Get client ID from plugin params + PluginHelper::importPlugin('mokojoomcross'); + $pluginParams = PluginHelper::getPlugin('mokojoomcross', $service->service_type); + $params = json_decode($pluginParams->params ?? '{}', true) ?: []; + + $clientId = $params['client_id'] ?? ''; + + if (empty($clientId)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::sprintf('COM_MOKOJOOMCROSS_OAUTH_NO_CLIENT_ID', ucfirst($service->service_type)), + 'error' + ); + + return; + } + + // Generate CSRF nonce and store in session + $nonce = bin2hex(random_bytes(16)); + Factory::getApplication()->getSession()->set('mokojoomcross.oauth_nonce', $nonce); + + $url = OAuthHelper::getAuthorizeUrl($service->service_type, $serviceId, $clientId, $nonce); + + if (!$url) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::sprintf('COM_MOKOJOOMCROSS_OAUTH_NOT_SUPPORTED', ucfirst($service->service_type)), + 'error' + ); + + return; + } + + $this->app->redirect($url); + } + + /** + * Handle OAuth callback from platform. + * + * Expects: code (string), state (base64 JSON with service_id) + */ + public function callback(): void + { + $code = $this->input->getString('code', ''); + $state = $this->input->getString('state', ''); + $error = $this->input->getString('error', ''); + + if ($error) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::sprintf('COM_MOKOJOOMCROSS_OAUTH_PLATFORM_ERROR', $error), + 'error' + ); + + return; + } + + if (empty($code) || empty($state)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::_('COM_MOKOJOOMCROSS_OAUTH_INVALID_CALLBACK'), + 'error' + ); + + return; + } + + $stateData = json_decode(base64_decode($state), true); + $serviceId = (int) ($stateData['service_id'] ?? 0); + $serviceType = $stateData['type'] ?? ''; + $stateNonce = $stateData['nonce'] ?? ''; + + if (!$serviceId || !$serviceType) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::_('COM_MOKOJOOMCROSS_OAUTH_INVALID_STATE'), + 'error' + ); + + return; + } + + // CSRF nonce validation — compare state nonce against session + $session = Factory::getApplication()->getSession(); + $sessionNonce = $session->get('mokojoomcross.oauth_nonce', ''); + $session->clear('mokojoomcross.oauth_nonce'); + + if (empty($stateNonce) || !hash_equals($sessionNonce, $stateNonce)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=services', false), + Text::_('COM_MOKOJOOMCROSS_OAUTH_INVALID_STATE'), + 'error' + ); + + return; + } + + // Get client credentials from plugin params + PluginHelper::importPlugin('mokojoomcross'); + $pluginParams = PluginHelper::getPlugin('mokojoomcross', $serviceType); + $params = json_decode($pluginParams->params ?? '{}', true) ?: []; + + $clientId = $params['client_id'] ?? ''; + $clientSecret = $params['client_secret'] ?? ''; + + $tokenData = OAuthHelper::exchangeCode($serviceType, $code, $clientId, $clientSecret); + + if (!empty($tokenData['error'])) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&task=service.edit&id=' . $serviceId, false), + Text::sprintf('COM_MOKOJOOMCROSS_OAUTH_TOKEN_ERROR', $tokenData['error']), + 'error' + ); + + return; + } + + OAuthHelper::storeToken($serviceId, $tokenData); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&task=service.edit&id=' . $serviceId, false), + Text::sprintf('COM_MOKOJOOMCROSS_OAUTH_SUCCESS', ucfirst($serviceType)), + 'success' + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/PostsController.php b/src/packages/com_mokojoomcross/src/Controller/PostsController.php index 79adc59..6adcff9 100644 --- a/src/packages/com_mokojoomcross/src/Controller/PostsController.php +++ b/src/packages/com_mokojoomcross/src/Controller/PostsController.php @@ -13,7 +13,10 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\Router\Route; class PostsController extends AdminController { @@ -21,4 +24,214 @@ class PostsController extends AdminController { return parent::getModel($name, $prefix, $config); } + + /** + * Schedule selected posts for a future date/time. + * + * @return void + */ + public function schedule(): void + { + $this->checkToken(); + + $ids = $this->input->get('cid', [], 'array'); + $scheduledAt = $this->input->getString('scheduled_at', ''); + + if (empty($ids)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::_('COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED'), + 'warning' + ); + return; + } + + if (empty($scheduledAt)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::_('COM_MOKOJOOMCROSS_SCHEDULE_NO_DATE'), + 'warning' + ); + return; + } + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + foreach ($ids as $id) { + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt)) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . (int) $id); + + $db->setQuery($query); + $db->execute(); + } + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::sprintf('COM_MOKOJOOMCROSS_POSTS_N_SCHEDULED', count($ids)), + 'success' + ); + } + + /** + * Retry selected failed/permanently_failed posts. + * + * @return void + */ + public function retrySelected(): void + { + $this->checkToken(); + + $ids = $this->input->get('cid', [], 'array'); + + if (empty($ids)) { + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::_('COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED'), + 'warning' + ); + return; + } + + $count = \Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::retryPosts($ids); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::sprintf('COM_MOKOJOOMCROSS_POSTS_N_RETRIED', $count), + 'success' + ); + } + + /** + * Re-queue all failed posts by resetting their status to queued and retry count to 0. + * + * @return void + */ + public function retryFailed(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::plural('COM_MOKOJOOMCROSS_POSTS_N_RETRIED', $count), + 'success' + ); + } + + /** + * Export posts as CSV download. + * + * @return void + */ + public function exportCsv(): void + { + $app = $this->app; + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.title', 'article_title'), + 'CONCAT(' . $db->quoteName('s.title') . ', ' . $db->quote(' (') . ', ' + . $db->quoteName('s.service_type') . ', ' . $db->quote(')') . ') AS service', + $db->quoteName('a.status'), + $db->quoteName('a.message'), + $db->quoteName('a.posted_at'), + $db->quoteName('a.error_message'), + $db->quoteName('a.platform_post_id'), + $db->quoteName('a.created'), + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'a')) + ->join('LEFT', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('a.article_id')) + ->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('a.service_id')) + ->order($db->quoteName('a.created') . ' DESC'); + + // Apply current filters + $status = $app->input->get('filter_status', '', 'string'); + + if (!empty($status)) { + $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); + } + + $serviceId = $app->input->getInt('filter_service_id', 0); + + if (!empty($serviceId)) { + $query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId); + } + + $search = $app->input->get('filter_search', '', 'string'); + + if (!empty($search)) { + $search = '%' . $db->escape(trim($search), true) . '%'; + $query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search) + . ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')'); + } + + $db->setQuery($query); + $rows = $db->loadAssocList() ?: []; + + $filename = 'mokojoomcross-posts-' . Factory::getDate()->format('Y-m-d') . '.csv'; + + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $app->sendHeaders(); + + $fp = fopen('php://output', 'w'); + fputcsv($fp, ['Article', 'Service', 'Status', 'Message', 'Posted At', 'Error', 'Platform Post ID', 'Created']); + + foreach ($rows as $row) { + fputcsv($fp, $row); + } + + fclose($fp); + + $app->close(); + } + + /** + * Purge (delete) all posts with status 'posted'. + * + * @return void + */ + public function purgePosted(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('posted')); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::plural('COM_MOKOJOOMCROSS_POSTS_N_PURGED', $count), + 'success' + ); + } } diff --git a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php b/src/packages/com_mokojoomcross/src/Controller/ServiceController.php index e9a3258..e3491d8 100644 --- a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php +++ b/src/packages/com_mokojoomcross/src/Controller/ServiceController.php @@ -13,8 +13,81 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Response\JsonResponse; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; class ServiceController extends FormController { + /** + * Test connection to a service by validating its credentials. + * + * @return void + */ + public function testConnection(): void + { + $app = $this->app; + $id = (int) $this->input->getInt('id', 0); + + try { + if ($id <= 0) { + throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE')); + } + + // Load the service record + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $service = $db->loadObject(); + + if (!$service) { + throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND')); + } + + // Get service plugins via dispatcher + PluginHelper::importPlugin('mokojoomcross'); + + $servicePlugins = []; + $app->getDispatcher()->dispatch( + 'onMokoJoomCrossGetServices', + new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins]) + ); + + // Find the matching plugin + $plugin = null; + + foreach ($servicePlugins as $sp) { + if ($sp instanceof MokoJoomCrossServiceInterface && $sp->getServiceType() === $service->service_type) { + $plugin = $sp; + break; + } + } + + if (!$plugin) { + throw new \RuntimeException(Text::sprintf('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN', $service->service_type)); + } + + // Decode credentials and validate + $credentials = json_decode($service->credentials ?: '{}', true) ?: []; + $result = $plugin->validateCredentials($credentials); + + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($result); + } catch (\Throwable $e) { + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($e); + } + + $app->close(); + } } diff --git a/src/packages/com_mokojoomcross/src/Controller/TemplateController.php b/src/packages/com_mokojoomcross/src/Controller/TemplateController.php new file mode 100644 index 0000000..5bee040 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Controller/TemplateController.php @@ -0,0 +1,20 @@ + + * @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\MokoJoomCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\FormController; + +class TemplateController extends FormController +{ +} diff --git a/src/packages/com_mokojoomcross/src/Controller/TemplatesController.php b/src/packages/com_mokojoomcross/src/Controller/TemplatesController.php new file mode 100644 index 0000000..6ff88c9 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Controller/TemplatesController.php @@ -0,0 +1,24 @@ + + * @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\MokoJoomCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\AdminController; + +class TemplatesController extends AdminController +{ + public function getModel($name = 'Template', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/src/packages/com_mokojoomcross/src/Helper/CrossPostDispatcher.php b/src/packages/com_mokojoomcross/src/Helper/CrossPostDispatcher.php new file mode 100644 index 0000000..bff7ef1 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/CrossPostDispatcher.php @@ -0,0 +1,451 @@ + + * @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\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; + +/** + * Static dispatcher for cross-posting content from any source plugin. + * + * Centralises the dispatch logic that was previously only in the system plugin, + * so content-type source plugins (articles, calendar events, gallery items) can + * trigger cross-posts without coupling to plg_system_mokojoomcross. + */ +class CrossPostDispatcher +{ + /** + * Dispatch an article-like payload to all enabled cross-post services. + * + * @param object $article Article or article-like object + * @param string $articleUrl Canonical URL for the content item + * @param string|null $contentType Content type context (e.g. 'com_content.article') + */ + public static function dispatch(object $article, string $articleUrl = '', ?string $contentType = null): void + { + $db = Factory::getDbo(); + + // Load all enabled services + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + $services = $db->loadObjectList(); + + if (empty($services)) { + return; + } + + // Import service plugins so they register with the dispatcher + PluginHelper::importPlugin('mokojoomcross'); + + // Collect registered service plugin instances. + // In Joomla 5+ with SubscriberInterface, plugins receive the Event object + // as their first argument. When they do $services[] = $this, they append to + // the Event via ArrayAccess at numeric indices starting at 1. + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]); + + try { + Factory::getApplication()->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event); + } catch (\Throwable $e) { + // Dispatcher may not be available in all contexts + } + + // Read plugins back from the Event's ArrayAccess indices + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + // Index by service type for lookup + $pluginMap = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoJoomCrossServiceInterface) { + $pluginMap[$plugin->getServiceType()] = $plugin; + } + } + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + + // Per-article selective cross-posting (#19) + $attribs = json_decode($article->attribs ?? '{}', true) ?: []; + $selectedServiceIds = $attribs['mokojoomcross_services'] ?? null; + $skipCrossPost = !empty($attribs['mokojoomcross_skip']); + + if ($skipCrossPost) { + return; + } + + // If specific services selected, convert to array of ints for filtering + if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) { + $selectedServiceIds = array_map('intval', $selectedServiceIds); + } else { + $selectedServiceIds = null; // null = post to all + } + + // Category routing rules — whitelist services by category + $categoryServiceIds = null; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select('service_id') + ->from($db->quoteName('#__mokojoomcross_category_rules')) + ->where($db->quoteName('category_id') . ' = ' . (int) $article->catid) + ->where($db->quoteName('published') . ' = 1'); + $db->setQuery($query); + $ruleIds = $db->loadColumn(); + + if (!empty($ruleIds)) { + $categoryServiceIds = array_map('intval', $ruleIds); + } + } + + // Determine service type filter from content type property + $serviceTypeFilter = $article->_content_type ?? null; + + foreach ($services as $service) { + // Category routing filter — if rules exist, only post to whitelisted services + if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) { + continue; + } + // Service type filter for non-article content types + if ($serviceTypeFilter !== null && $service->service_type !== $serviceTypeFilter) { + continue; + } + + // Per-article filter + if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) { + continue; + } + + // Duplicate guard + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')'); + + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + continue; + } + + $message = self::renderTemplate($article, $service); + + // Extract intro image for media attachment + $media = []; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $media[] = Uri::root() . ltrim($images->image_intro, '/'); + } + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => Factory::getDate()->toSql(), + 'modified' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokojoomcross_posts', $post); + $postId = $db->insertid(); + + // Resolve article URL + $url = $article->_article_url ?? $articleUrl; + + if (empty($url)) { + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id + . (!empty($article->catid) ? '&catid=' . $article->catid : ''); + } + + // Attempt immediate dispatch if service plugin is available + $plugin = $pluginMap[$service->service_type] ?? null; + + if ($plugin) { + self::executePost($db, $postId, $plugin, $message, $service, $media, $url); + } else { + self::log($db, $postId, $service->id, 'warning', + sprintf('No service plugin found for type "%s" — post remains queued', $service->service_type)); + } + } + } + + /** + * Execute a cross-post via the service plugin. + */ + private static function executePost($db, int $postId, MokoJoomCrossServiceInterface $plugin, string $message, object $service, array $media = [], string $articleUrl = ''): void + { + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + $credentials = json_decode($service->credentials ?: '{}', true) ?: []; + $params = json_decode($service->params ?: '{}', true) ?: []; + + if (!empty($articleUrl)) { + $params['_article_url'] = $articleUrl; + } + + // Lifecycle event: before post + $cancel = false; + $dispatcher = Factory::getApplication()->getDispatcher(); + + try { + $beforeEvent = new \Joomla\Event\Event('onMokoJoomCrossBeforePost', [$postId, &$message, $service->service_type, &$cancel]); + $dispatcher->dispatch('onMokoJoomCrossBeforePost', $beforeEvent); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + if ($cancel) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('cancelled')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'info', + sprintf('Post to %s cancelled by onMokoJoomCrossBeforePost event', $service->service_type)); + + return; + } + + try { + $result = $plugin->publish($message, $media, $credentials, $params); + + if (!empty($result['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($result['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'info', + sprintf('Posted to %s (platform ID: %s)', $service->service_type, $result['platform_post_id'] ?? 'n/a')); + + try { + $afterEvent = new \Joomla\Event\Event('onMokoJoomCrossAfterPost', [$postId, $service->service_type, $result]); + $dispatcher->dispatch('onMokoJoomCrossAfterPost', $afterEvent); + } catch (\Throwable $e) { + // Non-critical + } + } else { + $errorMsg = $result['response']['error'] ?? json_encode($result['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'error', + sprintf('Failed to post to %s: %s', $service->service_type, $errorMsg)); + + try { + $failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [$postId, $service->service_type, $errorMsg]); + $dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent); + } catch (\Throwable $e) { + // Non-critical + } + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'error', + sprintf('Exception posting to %s: %s', $service->service_type, $e->getMessage())); + + try { + $failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [$postId, $service->service_type, $e->getMessage()]); + $dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent); + } catch (\Throwable $ex) { + // Non-critical + } + } + } + + /** + * Render the message template for a service. + */ + private static function renderTemplate(object $article, object $service): string + { + $db = Factory::getDbo(); + + // Try service-specific template first, fall back to default + $query = $db->getQuery(true) + ->select($db->quoteName('template_body')) + ->from($db->quoteName('#__mokojoomcross_templates')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type) + . ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')') + ->order('CASE WHEN ' . $db->quoteName('service_type') . ' = ' + . $db->quote($service->service_type) . ' THEN 0 ELSE 1 END') + ->setLimit(1); + + $db->setQuery($query); + $template = $db->loadResult() ?: "{title}\n\n{url}"; + + // Build SEF article URL + $url = $article->_article_url + ?? (Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id + . (!empty($article->catid) ? '&catid=' . $article->catid : '')); + + // Resolve category name + $categoryName = ''; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($query); + $categoryName = $db->loadResult() ?: ''; + } + + // Resolve author name + $authorName = ''; + + if (!empty($article->created_by)) { + $query = $db->getQuery(true) + ->select($db->quoteName('name')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . (int) $article->created_by); + $db->setQuery($query); + $authorName = $db->loadResult() ?: ''; + } + + // Extract intro image + $introImage = ''; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $introImage = Uri::root() . ltrim($images->image_intro, '/'); + } + + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + + // Replace placeholders + $replacements = [ + '{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, + ]; + + $message = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Resolve custom field placeholders: {field:field_name} + $message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) { + $fieldName = $matches[1]; + $query = $db->getQuery(true) + ->select('fv.value') + ->from($db->quoteName('#__fields_values', 'fv')) + ->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id') + ->where('f.name = ' . $db->quote($fieldName)) + ->where('fv.item_id = ' . (int) $article->id); + $db->setQuery($query); + return $db->loadResult() ?: ''; + }, $message); + + return $message; + } + + /** + * Write an entry to the activity log. + */ + private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void + { + $log = (object) [ + 'post_id' => $postId, + 'service_id' => $serviceId, + 'level' => $level, + 'message' => mb_substr($message, 0, 2000), + 'context' => '{}', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokojoomcross_logs', $log); + } +} diff --git a/src/packages/com_mokojoomcross/src/Helper/MigrationHelper.php b/src/packages/com_mokojoomcross/src/Helper/MigrationHelper.php index dd3fed7..74129dd 100644 --- a/src/packages/com_mokojoomcross/src/Helper/MigrationHelper.php +++ b/src/packages/com_mokojoomcross/src/Helper/MigrationHelper.php @@ -16,28 +16,42 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; /** - * Migration helper for importing settings from Perfect Publisher Pro. + * Migration helper for importing settings from Perfect Publisher Pro (com_autotweet). * - * Reads Perfect Publisher Pro's component params and plugin configurations - * and maps them to MokoJoomCross service records. + * PP Pro stores channels in #__autotweet_channels with a channeltype_id FK + * to #__autotweet_channeltypes. Each channel has a JSON params column + * containing OAuth tokens, API keys, webhook URLs, etc. + * + * This helper reads those channels and creates MokoJoomCross service records. */ class MigrationHelper { /** - * Service type mapping from Perfect Publisher Pro to MokoJoomCross. - * - * @var array + * Channel type name → MokoJoomCross service type mapping. + * PP Pro channeltype names vary; we match common patterns. */ - private const SERVICE_MAP = [ - 'facebook' => 'facebook', - 'twitter' => 'twitter', - 'linkedin' => 'linkedin', - 'telegram' => 'telegram', + private const CHANNEL_MAP = [ + 'facebook' => 'facebook', + 'fb' => 'facebook', + 'twitter' => 'twitter', + 'tw' => 'twitter', + 'linkedin' => 'linkedin', + 'li' => 'linkedin', + 'telegram' => 'telegram', + 'tg' => 'telegram', + 'discord' => 'discord', + 'slack' => 'slack', + 'mastodon' => 'mastodon', ]; /** * Run the full migration from Perfect Publisher Pro. * + * Strategy: + * 1. Try reading #__autotweet_channels (PP Pro's channel table) + * 2. Fall back to reading component params if table doesn't exist + * 3. Create disabled MokoJoomCross service records + * * @return array ['migrated' => int, 'skipped' => int, 'errors' => string[]] */ public static function migrate(): array @@ -45,44 +59,106 @@ class MigrationHelper $db = Factory::getDbo(); $result = ['migrated' => 0, 'skipped' => 0, 'errors' => []]; - // Read Perfect Publisher Pro component params + // Check if PP Pro is installed + if (!self::isPPProInstalled($db)) { + $result['errors'][] = 'Perfect Publisher Pro (com_autotweet) is not installed.'; + + return $result; + } + + // Try channel-based migration first (PP Pro stores configs in #__autotweet_channels) + if (self::hasChannelTable($db)) { + $result = self::migrateFromChannels($db, $result); + } else { + // Fall back to component params extraction + $result = self::migrateFromParams($db, $result); + } + + // Clear migration flag from MokoJoomCross params + self::clearMigrationFlag($db); + + return $result; + } + + /** + * Check if PP Pro is installed. + */ + private static function isPPProInstalled($db): bool + { $query = $db->getQuery(true) - ->select($db->quoteName('params')) + ->select('COUNT(*)') ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' LIKE ' . $db->quote('%perfectpublisher%')) + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('com_autotweet') + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('%perfectpublisher%') . ')') ->where($db->quoteName('type') . ' = ' . $db->quote('component')); $db->setQuery($query); - $rawParams = $db->loadResult(); - if (!$rawParams) { - $result['errors'][] = 'Perfect Publisher Pro not found or has no configuration.'; + return (int) $db->loadResult() > 0; + } + + /** + * Check if the autotweet_channels table exists. + */ + private static function hasChannelTable($db): bool + { + $prefix = $db->getPrefix(); + + try { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote($prefix . 'autotweet_channels')); + + return !empty($db->loadResult()); + } catch (\Throwable $e) { + return false; + } + } + + /** + * Migrate from #__autotweet_channels table (primary method). + */ + private static function migrateFromChannels($db, array $result): array + { + // Load channels with their type names + $query = $db->getQuery(true) + ->select('c.id, c.name, c.published, c.params') + ->select($db->quoteName('ct.name', 'type_name')) + ->from($db->quoteName('#__autotweet_channels', 'c')) + ->join('LEFT', $db->quoteName('#__autotweet_channeltypes', 'ct') + . ' ON ' . $db->quoteName('ct.id') . ' = ' . $db->quoteName('c.channeltype_id')); + + $db->setQuery($query); + $channels = $db->loadObjectList(); + + if (empty($channels)) { + $result['errors'][] = 'No channels found in Perfect Publisher Pro.'; return $result; } - $params = json_decode($rawParams, true); + foreach ($channels as $channel) { + $typeName = strtolower(trim($channel->type_name ?? '')); - if (!is_array($params)) { - $result['errors'][] = 'Could not parse Perfect Publisher Pro configuration.'; + // Match to MokoJoomCross service type + $mjcType = null; - return $result; - } + foreach (self::CHANNEL_MAP as $pattern => $serviceType) { + if (str_contains($typeName, $pattern)) { + $mjcType = $serviceType; + break; + } + } - // Iterate known service mappings and create MokoJoomCross service records - foreach (self::SERVICE_MAP as $ppKey => $mjcType) { - $credentials = self::extractCredentials($params, $ppKey); - - if (empty($credentials)) { + if (!$mjcType) { $result['skipped']++; continue; } - // Check if service already exists + // Check for duplicate (same type + migrated alias) + $alias = $mjcType . '-pp-' . $channel->id; $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName('#__mokojoomcross_services')) - ->where($db->quoteName('service_type') . ' = ' . $db->quote($mjcType)); + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); $db->setQuery($query); if ((int) $db->loadResult() > 0) { @@ -90,60 +166,223 @@ class MigrationHelper continue; } - // Insert new service record + // Parse channel params to extract credentials + $channelParams = json_decode($channel->params ?: '{}', true) ?: []; + $credentials = self::mapChannelCredentials($mjcType, $channelParams); + + if (empty($credentials)) { + $result['skipped']++; + continue; + } + + // Create MokoJoomCross service record $service = (object) [ - 'title' => ucfirst($mjcType) . ' (migrated from PP Pro)', - 'alias' => $mjcType . '-migrated', + 'title' => $channel->name ?: ucfirst($mjcType) . ' (PP Pro #' . $channel->id . ')', + 'alias' => $alias, 'service_type' => $mjcType, 'credentials' => json_encode($credentials), 'params' => '{}', - 'published' => 0, // Disabled until user verifies + 'published' => 0, // Disabled — user must verify before enabling 'ordering' => 0, 'created' => Factory::getDate()->toSql(), 'modified' => Factory::getDate()->toSql(), 'created_by' => Factory::getApplication()->getIdentity()->id ?? 0, ]; - $db->insertObject('#__mokojoomcross_services', $service); - $result['migrated']++; + try { + $db->insertObject('#__mokojoomcross_services', $service); + $result['migrated']++; + } catch (\Throwable $e) { + $result['errors'][] = sprintf('Failed to create %s service: %s', $mjcType, $e->getMessage()); + } } - // Clear migration flag - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' . $db->quote('{}')) - ->where($db->quoteName('type') . ' = ' . $db->quote('component')) - ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); - $db->setQuery($query); - $db->execute(); - return $result; } /** - * Extract credentials for a specific service from PP Pro params. + * Map PP Pro channel params to MokoJoomCross credential format. * - * @param array $params PP Pro component params - * @param string $serviceKey Service key in PP Pro params - * - * @return array Credential key/value pairs (empty if none found) + * PP Pro stores various keys in channel params depending on the type. + * We normalize them to MokoJoomCross's expected credential structure. */ - private static function extractCredentials(array $params, string $serviceKey): array + private static function mapChannelCredentials(string $serviceType, array $channelParams): array { - $credentials = []; + $creds = ['mode' => 'custom']; - // PP Pro uses various key patterns: {service}_app_id, {service}_api_key, etc. - $prefixes = [$serviceKey . '_', $serviceKey . 'api_', $serviceKey . '-']; + // Common OAuth fields PP Pro uses + $oauthFields = ['access_token', 'access_secret', 'client_id', 'client_secret', + 'api_key', 'api_secret', 'app_id', 'app_secret', 'token']; - foreach ($params as $key => $value) { - foreach ($prefixes as $prefix) { - if (str_starts_with($key, $prefix) && !empty($value)) { - $cleanKey = str_replace($prefix, '', $key); - $credentials[$cleanKey] = $value; + switch ($serviceType) { + case 'facebook': + $creds['page_access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + $creds['page_id'] = $channelParams['page_id'] ?? $channelParams['pageid'] ?? ''; + break; + + case 'twitter': + $creds['bearer_token'] = $channelParams['bearer_token'] ?? ''; + $creds['api_key'] = $channelParams['api_key'] ?? $channelParams['consumer_key'] ?? ''; + $creds['api_secret'] = $channelParams['api_secret'] ?? $channelParams['consumer_secret'] ?? ''; + $creds['access_token'] = $channelParams['access_token'] ?? ''; + $creds['access_token_secret'] = $channelParams['access_secret'] ?? $channelParams['access_token_secret'] ?? ''; + break; + + case 'linkedin': + $creds['access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + $creds['organization_id'] = $channelParams['company_id'] ?? $channelParams['organization_id'] ?? ''; + $creds['person_id'] = $channelParams['person_id'] ?? $channelParams['member_id'] ?? ''; + break; + + case 'telegram': + $creds['bot_token'] = $channelParams['bot_token'] ?? $channelParams['token'] ?? $channelParams['api_key'] ?? ''; + $creds['chat_id'] = $channelParams['chat_id'] ?? $channelParams['channel_id'] ?? ''; + break; + + case 'discord': + $creds['webhook_url'] = $channelParams['webhook_url'] ?? $channelParams['webhook'] ?? ''; + break; + + case 'slack': + $creds['webhook_url'] = $channelParams['webhook_url'] ?? $channelParams['webhook'] ?? ''; + break; + + case 'mastodon': + $creds['instance_url'] = $channelParams['instance_url'] ?? $channelParams['server'] ?? ''; + $creds['access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + break; + + default: + // Generic: copy all non-empty params + foreach ($channelParams as $key => $value) { + if (!empty($value) && is_string($value)) { + $creds[$key] = $value; + } } + } + + // Remove empty credential values and the mode key for check + $check = array_filter($creds, fn($v, $k) => $k !== 'mode' && !empty($v), ARRAY_FILTER_USE_BOTH); + + return empty($check) ? [] : $creds; + } + + /** + * Fallback: migrate from component params when channel table doesn't exist. + */ + private static function migrateFromParams($db, array $result): array + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('com_autotweet') + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('%perfectpublisher%') . ')') + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + + $db->setQuery($query); + $rawParams = $db->loadResult(); + + if (!$rawParams) { + $result['errors'][] = 'No PP Pro configuration found.'; + + return $result; + } + + $params = json_decode($rawParams, true); + + if (!is_array($params)) { + $result['errors'][] = 'Could not parse PP Pro configuration.'; + + return $result; + } + + // Extract services from component params using prefix patterns + $servicePatterns = [ + 'facebook' => ['facebook_', 'fb_'], + 'twitter' => ['twitter_', 'tw_'], + 'linkedin' => ['linkedin_', 'li_'], + 'telegram' => ['telegram_', 'tg_'], + ]; + + foreach ($servicePatterns as $mjcType => $prefixes) { + $credentials = ['mode' => 'custom']; + $found = false; + + foreach ($params as $key => $value) { + foreach ($prefixes as $prefix) { + if (str_starts_with($key, $prefix) && !empty($value)) { + $cleanKey = substr($key, strlen($prefix)); + $credentials[$cleanKey] = $value; + $found = true; + } + } + } + + if (!$found) { + $result['skipped']++; + continue; + } + + // Duplicate check + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('service_type') . ' = ' . $db->quote($mjcType)) + ->where($db->quoteName('alias') . ' LIKE ' . $db->quote('%-migrated%')); + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + $result['skipped']++; + continue; + } + + $service = (object) [ + 'title' => ucfirst($mjcType) . ' (migrated from PP Pro)', + 'alias' => $mjcType . '-migrated', + 'service_type' => $mjcType, + 'credentials' => json_encode($credentials), + 'params' => '{}', + 'published' => 0, + 'ordering' => 0, + 'created' => Factory::getDate()->toSql(), + 'modified' => Factory::getDate()->toSql(), + 'created_by' => Factory::getApplication()->getIdentity()->id ?? 0, + ]; + + try { + $db->insertObject('#__mokojoomcross_services', $service); + $result['migrated']++; + } catch (\Throwable $e) { + $result['errors'][] = sprintf('Failed to create %s: %s', $mjcType, $e->getMessage()); } } - return $credentials; + return $result; + } + + /** + * Clear the migration flag from MokoJoomCross component params. + */ + private static function clearMigrationFlag($db): void + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + + unset($params['migration_available'], $params['migration_source_params']); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $db->execute(); } } diff --git a/src/packages/com_mokojoomcross/src/Helper/MokoJoomCrossHelper.php b/src/packages/com_mokojoomcross/src/Helper/MokoJoomCrossHelper.php new file mode 100644 index 0000000..5e015f8 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/MokoJoomCrossHelper.php @@ -0,0 +1,65 @@ + + * @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\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; + +/** + * Component helper — renders the admin submenu sidebar. + */ +class MokoJoomCrossHelper +{ + /** + * Configure the submenu links. + * + * Called from each view's addToolbar() to highlight the active item. + * + * @param string $activeView The current view name + * + * @return void + */ + public static function addSubmenu(string $activeView): void + { + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_('COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD'), + 'index.php?option=com_mokojoomcross&view=dashboard', + $activeView === 'dashboard' + ); + + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_('COM_MOKOJOOMCROSS_SUBMENU_POSTS'), + 'index.php?option=com_mokojoomcross&view=posts', + $activeView === 'posts' + ); + + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_('COM_MOKOJOOMCROSS_SUBMENU_SERVICES'), + 'index.php?option=com_mokojoomcross&view=services', + $activeView === 'services' + ); + + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_('COM_MOKOJOOMCROSS_SUBMENU_TEMPLATES'), + 'index.php?option=com_mokojoomcross&view=templates', + $activeView === 'templates' + ); + + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_('COM_MOKOJOOMCROSS_SUBMENU_LOGS'), + 'index.php?option=com_mokojoomcross&view=logs', + $activeView === 'logs' + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/Helper/OAuthHelper.php b/src/packages/com_mokojoomcross/src/Helper/OAuthHelper.php new file mode 100644 index 0000000..66c9c27 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/OAuthHelper.php @@ -0,0 +1,311 @@ + + * @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\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; + +/** + * OAuth helper for services requiring browser-based authorization. + * + * Handles the OAuth 2.0 authorization code flow: + * 1. Generate authorize URL → redirect user to platform + * 2. Platform redirects back with auth code + * 3. Exchange code for access token + * 4. Store token in service credentials + * + * Each platform has its own endpoints and scopes. The service plugin + * provides these via OAuthConfigInterface (if it supports OAuth). + */ +class OAuthHelper +{ + /** + * OAuth endpoint configs per service type. + */ + private const OAUTH_CONFIGS = [ + 'facebook' => [ + 'authorize_url' => 'https://www.facebook.com/v19.0/dialog/oauth', + 'token_url' => 'https://graph.facebook.com/v19.0/oauth/access_token', + 'scopes' => 'pages_manage_posts,pages_read_engagement', + ], + 'linkedin' => [ + 'authorize_url' => 'https://www.linkedin.com/oauth/v2/authorization', + 'token_url' => 'https://www.linkedin.com/oauth/v2/accessToken', + 'scopes' => 'w_member_social', + ], + 'twitter' => [ + 'authorize_url' => 'https://twitter.com/i/oauth2/authorize', + 'token_url' => 'https://api.twitter.com/2/oauth2/token', + 'scopes' => 'tweet.read tweet.write users.read', + ], + ]; + + /** + * Build the authorization URL for a given service. + * + * @param string $serviceType Service type (facebook, linkedin, twitter) + * @param int $serviceId Service record ID (passed through state param) + * @param string $clientId OAuth client/app ID + * + * @return string|null Authorization URL or null if not supported + */ + public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId, string $nonce = ''): ?string + { + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config) { + return null; + } + + $redirectUri = self::getCallbackUrl(); + $statePayload = ['service_id' => $serviceId, 'type' => $serviceType]; + + if (!empty($nonce)) { + $statePayload['nonce'] = $nonce; + } + + $state = base64_encode(json_encode($statePayload)); + + $params = [ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => $config['scopes'], + 'state' => $state, + ]; + + // Twitter uses PKCE + if ($serviceType === 'twitter') { + $verifier = bin2hex(random_bytes(32)); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + // Store verifier in session for token exchange + Factory::getApplication()->getSession()->set('mokojoomcross.pkce_verifier', $verifier); + + $params['code_challenge'] = $challenge; + $params['code_challenge_method'] = 'S256'; + } + + return $config['authorize_url'] . '?' . http_build_query($params); + } + + /** + * Exchange authorization code for access token. + * + * @param string $serviceType Service type + * @param string $code Authorization code from callback + * @param string $clientId OAuth client ID + * @param string $clientSecret OAuth client secret + * + * @return array ['access_token' => '...', 'expires_in' => N, ...] or ['error' => '...'] + */ + public static function exchangeCode(string $serviceType, string $code, string $clientId, string $clientSecret): array + { + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config) { + return ['error' => 'Unsupported service type for OAuth']; + } + + $postData = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => self::getCallbackUrl(), + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + + // Twitter PKCE + if ($serviceType === 'twitter') { + $verifier = Factory::getApplication()->getSession()->get('mokojoomcross.pkce_verifier', ''); + $postData['code_verifier'] = $verifier; + } + + $ch = curl_init($config['token_url']); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postData), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['access_token'])) { + return $data; + } + + return ['error' => $data['error_description'] ?? $data['error'] ?? 'Token exchange failed']; + } + + /** + * Store OAuth token in the service credentials. + * + * @param int $serviceId Service record ID + * @param array $tokenData Token response from platform + * + * @return bool + */ + public static function storeToken(int $serviceId, array $tokenData): bool + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('credentials')) + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $credentials = json_decode($db->loadResult() ?: '{}', true) ?: []; + + $credentials['access_token'] = $tokenData['access_token']; + $credentials['mode'] = 'custom'; + + if (!empty($tokenData['refresh_token'])) { + $credentials['refresh_token'] = $tokenData['refresh_token']; + } + + if (!empty($tokenData['expires_in'])) { + $credentials['token_expires'] = time() + (int) $tokenData['expires_in']; + } + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_services')) + ->set($db->quoteName('credentials') . ' = ' . $db->quote(json_encode($credentials))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $db->execute(); + + return true; + } + + /** + * Refresh an OAuth token if it has expired. + * + * Checks `token_expires` in the credentials array. If the token is expired + * and a refresh_token is available, performs the refresh grant and updates + * both the DB and the passed-in credentials array. + * + * @param int $serviceId Service record ID + * @param array &$credentials Credentials array (updated by reference on refresh) + * + * @return bool True if token was refreshed, false otherwise + */ + public static function refreshTokenIfNeeded(int $serviceId, array &$credentials): bool + { + // No expiry set — nothing to refresh + if (empty($credentials['token_expires'])) { + return false; + } + + // Token not yet expired + if ((int) $credentials['token_expires'] >= time()) { + return false; + } + + // Expired but no refresh token available + if (empty($credentials['refresh_token'])) { + return false; + } + + // Look up the service type from DB + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('service_type')) + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + $db->setQuery($query); + $serviceType = $db->loadResult(); + + if (!$serviceType) { + return false; + } + + // Get OAuth config for this service type + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config || empty($config['token_url'])) { + return false; + } + + // POST refresh token grant + $postData = [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $credentials['refresh_token'], + ]; + + // Include client credentials if available + if (!empty($credentials['client_id'])) { + $postData['client_id'] = $credentials['client_id']; + } + + if (!empty($credentials['client_secret'])) { + $postData['client_secret'] = $credentials['client_secret']; + } + + $ch = curl_init($config['token_url']); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postData), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['access_token'])) { + // Store updated token in DB + self::storeToken($serviceId, $data); + + // Update credentials by reference + $credentials['access_token'] = $data['access_token']; + + if (!empty($data['refresh_token'])) { + $credentials['refresh_token'] = $data['refresh_token']; + } + + if (!empty($data['expires_in'])) { + $credentials['token_expires'] = time() + (int) $data['expires_in']; + } + + return true; + } + + return false; + } + + /** + * Get the OAuth callback URL for this Joomla installation. + * + * @return string + */ + public static function getCallbackUrl(): string + { + return Uri::root() . 'administrator/index.php?option=com_mokojoomcross&task=oauth.callback'; + } +} diff --git a/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php new file mode 100644 index 0000000..db06b93 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php @@ -0,0 +1,776 @@ + + * @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\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; + +/** + * Shared queue processor used by: + * - System plugin onAfterRender (page-load processing) + * - Task scheduler plugin (Joomla scheduled task) + * + * Handles: queued posts, failed retries, scheduled posts, and log cleanup. + * Uses a simple DB-based lock to prevent concurrent execution. + */ +class QueueProcessor +{ + /** + * Process the post queue: dispatch queued posts, retry failed, fire scheduled. + * + * @param int $batchSize Max posts to process per run + * + * @return array ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public static function processQueue(int $batchSize = 10): array + { + $result = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0]; + + if (!self::acquireLock()) { + $result['skipped'] = -1; + + return $result; + } + + try { + $db = Factory::getDbo(); + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $now = Factory::getDate()->toSql(); + + // Build service plugin map + $pluginMap = self::getServicePluginMap(); + + // 1. Process queued posts + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('p.scheduled_at') . ' IS NULL OR ' + . $db->quoteName('p.scheduled_at') . ' <= ' . $db->quote($now) . ')') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.created') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $queuedPosts = $db->loadObjectList() ?: []; + + // 2. Process failed posts eligible for retry (exponential backoff) + // Retry 1 waits retryDelay, retry 2 waits retryDelay*2, retry 3 waits retryDelay*4, etc. + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('p.retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('p.modified') . ' <= DATE_SUB(NOW(), INTERVAL (' + . (int) $retryDelay . ' * POW(2, ' . $db->quoteName('p.retry_count') . ')) SECOND)') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.modified') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $retryPosts = $db->loadObjectList() ?: []; + + $allPosts = array_merge($queuedPosts, $retryPosts); + + foreach ($allPosts as $post) { + $result['processed']++; + + $plugin = $pluginMap[$post->service_type] ?? null; + + if (!$plugin) { + $result['skipped']++; + continue; + } + + $isRetry = ($post->status === 'failed'); + + if ($isRetry) { + $newRetryCount = (int) $post->retry_count + 1; + + // If this is the last retry attempt, mark permanently failed on failure + if ($newRetryCount >= $maxRetry) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('permanently_failed')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->set($db->quoteName('error_message') . ' = CONCAT(' . $db->quoteName('error_message') . ', ' . $db->quote(' [max retries exceeded]') . ')') + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Permanently failed %s: max retries (%d) exceeded', $post->service_type, $maxRetry)); + + $result['failed']++; + continue; + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + } + + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + $credentials = json_decode($post->credentials ?: '{}', true) ?: []; + $params = json_decode($post->service_params ?: '{}', true) ?: []; + + // Token auto-refresh before posting + OAuthHelper::refreshTokenIfNeeded((int) $post->service_id, $credentials); + + // Extract intro image for media attachment + $media = []; + + if (!empty($post->article_id)) { + $imgQuery = $db->getQuery(true) + ->select($db->quoteName('images')) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . (int) $post->article_id); + $db->setQuery($imgQuery); + $imgJson = $db->loadResult(); + + if ($imgJson) { + $imgData = json_decode($imgJson); + + if (!empty($imgData->image_intro)) { + $media[] = Uri::root() . ltrim($imgData->image_intro, '/'); + } + } + } + + // Lifecycle event: before post + $cancel = false; + $message = $post->message; + + try { + $dispatcher = Factory::getApplication()->getDispatcher(); + $beforeEvent = new \Joomla\Event\Event('onMokoJoomCrossBeforePost', [(int) $post->id, &$message, $post->service_type, &$cancel]); + $dispatcher->dispatch('onMokoJoomCrossBeforePost', $beforeEvent); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + if ($cancel) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('cancelled')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('Post to %s cancelled by onMokoJoomCrossBeforePost event', $post->service_type)); + + $result['skipped']++; + continue; + } + + try { + $apiResult = $plugin->publish($message, $media, $credentials, $params); + + if (!empty($apiResult['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($apiResult['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('%s to %s (ID: %s)', $isRetry ? 'Retry succeeded' : 'Posted', $post->service_type, $apiResult['platform_post_id'] ?? 'n/a')); + + // Lifecycle event: after successful post + try { + $afterEvent = new \Joomla\Event\Event('onMokoJoomCrossAfterPost', [(int) $post->id, $post->service_type, $apiResult]); + $dispatcher->dispatch('onMokoJoomCrossAfterPost', $afterEvent); + } catch (\Throwable $e) { + // Non-critical + } + + $result['succeeded']++; + } else { + $errorMsg = $apiResult['response']['error'] ?? json_encode($apiResult['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Failed %s: %s', $post->service_type, mb_substr($errorMsg, 0, 500))); + + // Lifecycle event: post failed + try { + $failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [(int) $post->id, $post->service_type, $errorMsg]); + $dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent); + } catch (\Throwable $e) { + // Non-critical + } + + $result['failed']++; + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Exception %s: %s', $post->service_type, mb_substr($e->getMessage(), 0, 500))); + + // Lifecycle event: post failed (exception) + try { + $failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [(int) $post->id, $post->service_type, $e->getMessage()]); + $dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent); + } catch (\Throwable $ex) { + // Non-critical + } + + $result['failed']++; + } + } + + // 3. Clean up old logs + self::cleanupLogs($db, $componentParams); + + } finally { + self::releaseLock(); + } + + return $result; + } + + /** + * Process evergreen re-shares: find articles marked as evergreen whose last + * successful post to each service was longer ago than the configured interval, + * and create new queue entries for them. + * + * @return array ['queued' => int] + */ + public static function processEvergreen(): array + { + $result = ['queued' => 0]; + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$componentParams->get('evergreen_enabled', 1)) { + return $result; + } + + $defaultInterval = (int) $componentParams->get('evergreen_default_interval', 30); + $maxPerRun = (int) $componentParams->get('evergreen_max_per_run', 3); + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + // Find published articles with evergreen=1 in attribs + $query = $db->getQuery(true) + ->select('c.id, c.attribs') + ->from($db->quoteName('#__content', 'c')) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('c.attribs') . ' LIKE ' . $db->quote('%"mokojoomcross_evergreen":"1"%')); + + $db->setQuery($query); + $articles = $db->loadObjectList() ?: []; + + if (empty($articles)) { + return $result; + } + + // Load all published services + $query = $db->getQuery(true) + ->select('id, service_type') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('published') . ' = 1'); + + $db->setQuery($query); + $services = $db->loadObjectList() ?: []; + + if (empty($services)) { + return $result; + } + + // Import service plugins (not used for direct dispatch here, but ensures + // they are loaded in case any lifecycle events depend on them) + PluginHelper::importPlugin('mokojoomcross'); + + foreach ($articles as $article) { + if ($result['queued'] >= $maxPerRun) { + break; + } + + $attribs = json_decode($article->attribs ?? '{}', true) ?: []; + $interval = (int) ($attribs['mokojoomcross_evergreen_interval'] ?? $defaultInterval); + + if ($interval < 1) { + $interval = $defaultInterval; + } + + // Per-article service filter + $selectedServiceIds = $attribs['mokojoomcross_services'] ?? null; + + if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) { + $selectedServiceIds = array_map('intval', $selectedServiceIds); + } else { + $selectedServiceIds = null; + } + + // Load the full article for template rendering + $fullArticle = null; + + foreach ($services as $service) { + if ($result['queued'] >= $maxPerRun) { + break; + } + + // Per-article service filter + if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) { + continue; + } + + // Check last successful post for this article+service + $query = $db->getQuery(true) + ->select($db->quoteName('posted_at')) + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) + ->where($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->order($db->quoteName('posted_at') . ' DESC') + ->setLimit(1); + + $db->setQuery($query); + $lastPosted = $db->loadResult(); + + if (empty($lastPosted)) { + // Never posted — skip, the initial cross-post will handle it + continue; + } + + // Check if interval has elapsed + $lastDate = Factory::getDate($lastPosted); + $dueDate = Factory::getDate($lastPosted . ' + ' . $interval . ' days'); + + if ($dueDate->toUnix() > Factory::getDate()->toUnix()) { + // Not due yet + continue; + } + + // Skip if there's already a queued/posting entry + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posting') . ')'); + + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + continue; + } + + // Load full article if not already loaded + if ($fullArticle === null) { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . (int) $article->id); + $db->setQuery($query); + $fullArticle = $db->loadObject(); + + if (!$fullArticle) { + break; + } + } + + // Render message using default template + $template = $componentParams->get('default_template', "{title}\n\n{url}"); + $message = self::renderEvergreenMessage($db, $fullArticle, $template); + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__mokojoomcross_posts', $post); + + self::log($db, $db->insertid(), (int) $service->id, 'info', + sprintf('Evergreen re-share queued for article %d to %s (interval: %d days)', + $article->id, $service->service_type, $interval)); + + $result['queued']++; + } + } + + return $result; + } + + /** + * Render a message for an evergreen re-share using the default template. + */ + private static function renderEvergreenMessage($db, object $article, string $template): string + { + $url = \Joomla\CMS\Uri\Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $url .= '&catid=' . $article->catid; + } + + $categoryName = ''; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($query); + $categoryName = $db->loadResult() ?: ''; + } + + $authorName = ''; + + if (!empty($article->created_by)) { + $query = $db->getQuery(true) + ->select($db->quoteName('name')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . (int) $article->created_by); + $db->setQuery($query); + $authorName = $db->loadResult() ?: ''; + } + + $introImage = ''; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $introImage = \Joomla\CMS\Uri\Uri::root() . ltrim($images->image_intro, '/'); + } + + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + + $replacements = [ + '{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, + ]; + + $message = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Resolve custom field placeholders: {field:field_name} + $message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) { + $fieldName = $matches[1]; + $query = $db->getQuery(true) + ->select('fv.value') + ->from($db->quoteName('#__fields_values', 'fv')) + ->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id') + ->where('f.name = ' . $db->quote($fieldName)) + ->where('fv.item_id = ' . (int) $article->id); + $db->setQuery($query); + return $db->loadResult() ?: ''; + }, $message); + + return $message; + } + + /** + * Manually retry one or more failed/permanently_failed posts. + * + * Resets status to 'queued' and retry_count to 0 so the queue processor + * picks them up on the next run. + * + * @param array $postIds Post IDs to retry + * + * @return int Number of posts re-queued + */ + public static function retryPosts(array $postIds): int + { + if (empty($postIds)) { + return 0; + } + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + $ids = implode(',', array_map('intval', $postIds)); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' IN (' . $ids . ')') + ->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')'); + + $db->setQuery($query); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) { + self::log($db, null, null, 'info', sprintf('Manual retry: %d post(s) re-queued', $count)); + } + + return $count; + } + + /** + * Retry all failed posts for a specific service. + * + * @param int $serviceId Service ID + * + * @return int Number of posts re-queued + */ + public static function retryService(int $serviceId): int + { + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('service_id') . ' = ' . $serviceId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')'); + + $db->setQuery($query); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) { + self::log($db, null, $serviceId, 'info', sprintf('Bulk retry: %d post(s) re-queued for service %d', $count, $serviceId)); + } + + return $count; + } + + /** + * Check if there are pending items in the queue. + * + * @return bool + */ + public static function hasPendingWork(): bool + { + $db = Factory::getDbo(); + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); + $now = Factory::getDate()->toSql(); + + // Queued posts ready to go + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('scheduled_at') . ' IS NULL OR ' + . $db->quoteName('scheduled_at') . ' <= ' . $db->quote($now) . ')'); + $db->setQuery($query); + $queued = (int) $db->loadResult(); + + // Failed posts eligible for retry + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('modified') . ' <= ' . $db->quote($retryAfter)); + $db->setQuery($query); + $retryable = (int) $db->loadResult(); + + return ($queued + $retryable) > 0; + } + + /** + * Import mokojoomcross plugins and build a type → plugin instance map. + * + * @return array + */ + private static function getServicePluginMap(): array + { + PluginHelper::importPlugin('mokojoomcross'); + + // In Joomla 5+ with SubscriberInterface, plugins receive the Event object + // as their first argument. When they do $services[] = $this, they append to + // the Event via ArrayAccess at numeric indices starting at 1. + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]); + + try { + Factory::getApplication()->getDispatcher()->dispatch( + 'onMokoJoomCrossGetServices', + $event + ); + } catch (\Throwable $e) { + // Dispatcher may not be available in all contexts + } + + // Read plugins back from the Event's ArrayAccess indices + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + $map = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoJoomCrossServiceInterface) { + $map[$plugin->getServiceType()] = $plugin; + } + } + + return $map; + } + + /** + * Delete logs older than the configured retention period. + */ + private static function cleanupLogs($db, $componentParams): void + { + $retentionDays = (int) $componentParams->get('log_retention_days', 90); + + if ($retentionDays <= 0) { + return; + } + + $cutoff = Factory::getDate('now - ' . $retentionDays . ' days')->toSql(); + + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokojoomcross_logs')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)); + + $db->setQuery($query); + $db->execute(); + } + + /** + * Acquire a MySQL advisory lock to prevent concurrent queue processing. + * + * Uses GET_LOCK() which is atomic — no race condition possible. + * The 0 timeout means non-blocking (returns immediately if lock is held). + * MySQL automatically releases the lock if the connection drops. + */ + private static function acquireLock(): bool + { + $db = Factory::getDbo(); + $db->setQuery("SELECT GET_LOCK('mokojoomcross_queue', 0)"); + + return (int) $db->loadResult() === 1; + } + + /** + * Release the MySQL advisory lock. + */ + private static function releaseLock(): void + { + $db = Factory::getDbo(); + $db->setQuery("SELECT RELEASE_LOCK('mokojoomcross_queue')"); + $db->execute(); + } + + /** + * Write a log entry. + */ + private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void + { + $log = (object) [ + 'post_id' => $postId, + 'service_id' => $serviceId, + 'level' => $level, + 'message' => mb_substr($message, 0, 2000), + 'context' => '{}', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokojoomcross_logs', $log); + } +} diff --git a/src/packages/com_mokojoomcross/src/Helper/ServiceIconHelper.php b/src/packages/com_mokojoomcross/src/Helper/ServiceIconHelper.php new file mode 100644 index 0000000..6f67e47 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/ServiceIconHelper.php @@ -0,0 +1,96 @@ + + * @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\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +/** + * Static helper that maps service types to Joomla Bootstrap icons. + */ +class ServiceIconHelper +{ + /** + * Map of service type identifiers to icon CSS classes. + * + * @var array + */ + private const ICONS = [ + // Social + 'facebook' => 'icon-facebook', + 'twitter' => 'icon-twitter', + 'linkedin' => 'icon-linkedin', + 'mastodon' => 'icon-globe', + 'bluesky' => 'icon-cloud', + 'threads' => 'icon-comments', + 'pinterest' => 'icon-thumbtack', + 'reddit' => 'icon-comments-alt', + 'tumblr' => 'icon-pencil-alt', + 'tiktok' => 'icon-play-circle', + 'nostr' => 'icon-key', + 'activitypub' => 'icon-network-wired', + // Chat + 'telegram' => 'icon-paper-plane', + 'discord' => 'icon-headset', + 'slack' => 'icon-hashtag', + 'teams' => 'icon-users', + 'googlechat' => 'icon-comment', + 'whatsapp' => 'icon-mobile', + 'matrix' => 'icon-th', + 'ntfy' => 'icon-bell', + // Email + 'mailchimp' => 'icon-envelope', + 'sendgrid' => 'icon-envelope-open', + 'brevo' => 'icon-at', + 'convertkit' => 'icon-mail-bulk', + 'constantcontact' => 'icon-address-book', + // Publishing + 'medium' => 'icon-book', + 'wordpress' => 'icon-blog', + 'devto' => 'icon-code', + 'ghost' => 'icon-ghost', + 'hashnode' => 'icon-newspaper', + 'blogger' => 'icon-rss', + // Business + 'googlebusiness' => 'icon-store', + // Universal + 'webhook' => 'icon-plug', + 'rssfeed' => 'icon-rss-square', + ]; + + /** + * Get the icon CSS class for a service type. + * + * @param string $serviceType The service type identifier + * + * @return string Icon CSS class + */ + public static function getIcon(string $serviceType): string + { + return self::ICONS[$serviceType] ?? 'icon-share-alt'; + } + + /** + * Render an icon span element for a service type. + * + * @param string $serviceType The service type identifier + * @param string $extraClass Additional CSS classes to append + * + * @return string HTML span element + */ + public static function renderIcon(string $serviceType, string $extraClass = ''): string + { + $icon = self::getIcon($serviceType); + $class = trim($icon . ' ' . htmlspecialchars($extraClass, ENT_QUOTES, 'UTF-8')); + + return ''; + } +} diff --git a/src/packages/com_mokojoomcross/src/Model/DashboardModel.php b/src/packages/com_mokojoomcross/src/Model/DashboardModel.php index dcbf93c..d09cda2 100644 --- a/src/packages/com_mokojoomcross/src/Model/DashboardModel.php +++ b/src/packages/com_mokojoomcross/src/Model/DashboardModel.php @@ -70,4 +70,127 @@ class DashboardModel extends BaseDatabaseModel return !empty($params['migration_available']); } + + /** + * Get recent activity log entries. + * + * @param int $limit Number of entries to return + * + * @return array + */ + public function getRecentActivity(int $limit = 10): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select('l.*, s.title AS service_title, s.service_type') + ->from($db->quoteName('#__mokojoomcross_logs', 'l')) + ->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('l.service_id')) + ->order($db->quoteName('l.created') . ' DESC'); + + $db->setQuery($query, 0, $limit); + + return $db->loadObjectList() ?: []; + } + + /** + * Get posts-per-service breakdown for the analytics chart. + * + * @param string|null $since Only count posts created on or after this datetime + * + * @return array [['service_type' => '...', 'posted' => N, 'failed' => N, 'queued' => N], ...] + */ + public function getServiceBreakdown(?string $since = null): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('s.id', 'service_id'), + $db->quoteName('s.service_type'), + $db->quoteName('s.title', 'service_title'), + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS posted', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('queued') . ' THEN 1 ELSE 0 END) AS queued', + 'COUNT(*) AS total', + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->group($db->quoteName(['s.id', 's.service_type', 's.title'])) + ->order('total DESC'); + + if ($since !== null) { + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + } + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Get posts-per-day for the last N days (for trend chart). + * + * @param int $days Number of days to look back + * + * @return array [['day' => '2026-05-28', 'posted' => N, 'failed' => N], ...] + */ + public function getDailyTrend(int $days = 14): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d'); + + $query = $db->getQuery(true) + ->select([ + 'DATE(' . $db->quoteName('created') . ') AS day', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS posted', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed', + 'COUNT(*) AS total', + ]) + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where('DATE(' . $db->quoteName('created') . ') >= ' . $db->quote($cutoff)) + ->group('DATE(' . $db->quoteName('created') . ')') + ->order('day ASC'); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Get most cross-posted articles. + * + * @param int $limit Number of articles + * @param string|null $since Only count posts created on or after this datetime + * + * @return array + */ + public function getTopArticles(int $limit = 5, ?string $since = null): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.id'), + $db->quoteName('c.title'), + 'COUNT(*) AS post_count', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success_count', + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id')) + ->group($db->quoteName(['c.id', 'c.title'])) + ->order('post_count DESC'); + + if ($since !== null) { + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + } + + $db->setQuery($query, 0, $limit); + + return $db->loadAssocList() ?: []; + } } diff --git a/src/packages/com_mokojoomcross/src/Model/PostModel.php b/src/packages/com_mokojoomcross/src/Model/PostModel.php new file mode 100644 index 0000000..fe5ad1e --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Model/PostModel.php @@ -0,0 +1,83 @@ + + * @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\MokoJoomCross\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +class PostModel extends AdminModel +{ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokojoomcross.post', + 'post', + ['control' => 'jform', 'load_data' => $loadData] + ); + + if (empty($form)) { + return false; + } + + // Lock article_id and service_id on existing records + $id = $this->getState('post.id', 0); + + if ($id > 0) { + $form->setFieldAttribute('article_id', 'readonly', 'true'); + $form->setFieldAttribute('service_id', 'readonly', 'true'); + } + + return $form; + } + + protected function loadFormData() + { + return $this->getItem(); + } + + /** + * Prepare and sanitise the table prior to saving. + */ + protected function prepareTable($table) + { + $now = Factory::getDate()->toSql(); + + if (empty($table->id)) { + $table->created = $now; + $table->modified = $now; + + if (empty($table->status)) { + $table->status = empty($table->scheduled_at) ? 'queued' : 'scheduled'; + } + + if (empty($table->retry_count)) { + $table->retry_count = 0; + } + + if (empty($table->platform_post_id)) { + $table->platform_post_id = ''; + } + + if (empty($table->platform_response)) { + $table->platform_response = ''; + } + + if (empty($table->error_message)) { + $table->error_message = ''; + } + } else { + $table->modified = $now; + } + } +} diff --git a/src/packages/com_mokojoomcross/src/Model/PostsModel.php b/src/packages/com_mokojoomcross/src/Model/PostsModel.php index 2260a41..7168618 100644 --- a/src/packages/com_mokojoomcross/src/Model/PostsModel.php +++ b/src/packages/com_mokojoomcross/src/Model/PostsModel.php @@ -65,6 +65,22 @@ class PostsModel extends ListModel $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); } + // Filter by service + $serviceId = $this->getState('filter.service_id'); + + if (!empty($serviceId)) { + $query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId); + } + + // Filter by search (article title or message content) + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = '%' . $db->escape(trim($search), true) . '%'; + $query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search) + . ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')'); + } + // Ordering $orderCol = $this->state->get('list.ordering', 'a.created'); $orderDirn = $this->state->get('list.direction', 'DESC'); diff --git a/src/packages/com_mokojoomcross/src/Model/ServiceModel.php b/src/packages/com_mokojoomcross/src/Model/ServiceModel.php index 1e71511..4c4ebd9 100644 --- a/src/packages/com_mokojoomcross/src/Model/ServiceModel.php +++ b/src/packages/com_mokojoomcross/src/Model/ServiceModel.php @@ -13,6 +13,8 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Model; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Filter\OutputFilter; use Joomla\CMS\MVC\Model\AdminModel; class ServiceModel extends AdminModel @@ -43,12 +45,77 @@ class ServiceModel extends AdminModel /** * Method to get the data that should be injected in the form. * + * Expands the JSON credentials column back into individual cred_* form fields + * so they are populated when editing an existing service. + * * @return mixed The data for the form */ protected function loadFormData() { $data = $this->getItem(); + if ($data && !empty($data->credentials)) { + $credentials = json_decode($data->credentials, true) ?: []; + $serviceType = $data->service_type ?? ''; + + foreach ($credentials as $key => $value) { + // Map credential keys back to form field names. + // The mode field has no service type prefix. + if ($key === 'mode') { + $data->cred_mode = $value; + } else { + $data->{'cred_' . $serviceType . '_' . $key} = $value; + } + } + } + return $data; } + + /** + * Override save to collect cred_* form fields into the credentials JSON column. + * + * The service form has individual fields (cred_twitter_api_key, cred_facebook_page_id, etc.) + * but the database stores them as a single JSON blob in the `credentials` column. + * + * @param array $data The form data + * + * @return boolean True on success + */ + public function save($data) + { + $serviceType = $data['service_type'] ?? ''; + $credentials = []; + $credPrefix = 'cred_'; + + // Collect all cred_* fields into the credentials array + foreach ($data as $key => $value) { + if (strpos($key, $credPrefix) !== 0) { + continue; + } + + $credKey = substr($key, strlen($credPrefix)); + + // The mode field is shared across service types (no service_type prefix) + if ($credKey === 'mode') { + $credentials['mode'] = $value; + } elseif ($serviceType && strpos($credKey, $serviceType . '_') === 0) { + // Strip the service_type prefix: cred_twitter_api_key -> api_key + $strippedKey = substr($credKey, strlen($serviceType) + 1); + $credentials[$strippedKey] = $value; + } + } + + // Store the credentials JSON + $data['credentials'] = !empty($credentials) ? json_encode($credentials) : '{}'; + + // Remove individual cred_* fields so they don't cause column-not-found errors + foreach (array_keys($data) as $key) { + if (strpos($key, $credPrefix) === 0) { + unset($data[$key]); + } + } + + return parent::save($data); + } } diff --git a/src/packages/com_mokojoomcross/src/Model/ServiceStatsModel.php b/src/packages/com_mokojoomcross/src/Model/ServiceStatsModel.php new file mode 100644 index 0000000..029b8cf --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Model/ServiceStatsModel.php @@ -0,0 +1,185 @@ + + * @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\MokoJoomCross\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +/** + * Per-service analytics drill-down model. + */ +class ServiceStatsModel extends BaseDatabaseModel +{ + /** + * Get the service ID from the request. + * + * @return int + */ + public function getServiceId(): int + { + return Factory::getApplication()->input->getInt('id', 0); + } + + /** + * Load a single service record by ID. + * + * @param int $id Service ID + * + * @return object|null + */ + public function getService(int $id = 0): ?object + { + if ($id === 0) { + $id = $this->getServiceId(); + } + + if ($id === 0) { + return null; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . (int) $id); + + $db->setQuery($query); + + return $db->loadObject() ?: null; + } + + /** + * Get post status counts for a specific service. + * + * @param int $serviceId Service ID + * + * @return object Object with total, posted, failed, queued properties + */ + public function getPostStats(int $serviceId): object + { + $db = $this->getDatabase(); + + $stats = new \stdClass(); + + foreach (['queued', 'posted', 'failed'] as $status) { + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('service_id') . ' = ' . (int) $serviceId) + ->where($db->quoteName('status') . ' = ' . $db->quote($status)); + $db->setQuery($query); + $stats->{$status} = (int) $db->loadResult(); + } + + $stats->total = $stats->queued + $stats->posted + $stats->failed; + + return $stats; + } + + /** + * Get daily post trend for a specific service. + * + * @param int $serviceId Service ID + * @param int $days Number of days to look back + * + * @return array [['day' => '2026-05-28', 'posted' => N, 'failed' => N], ...] + */ + public function getDailyTrend(int $serviceId, int $days = 30): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d'); + + $query = $db->getQuery(true) + ->select([ + 'DATE(' . $db->quoteName('created') . ') AS day', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS posted', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed', + 'COUNT(*) AS total', + ]) + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('service_id') . ' = ' . (int) $serviceId) + ->where('DATE(' . $db->quoteName('created') . ') >= ' . $db->quote($cutoff)) + ->group('DATE(' . $db->quoteName('created') . ')') + ->order('day ASC'); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Get recent posts for a specific service with article titles. + * + * @param int $serviceId Service ID + * @param int $limit Number of posts to return + * + * @return array + */ + public function getRecentPosts(int $serviceId, int $limit = 20): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('p.id'), + $db->quoteName('p.status'), + $db->quoteName('p.posted_at'), + $db->quoteName('p.created'), + $db->quoteName('p.error_message'), + $db->quoteName('p.retry_count'), + $db->quoteName('c.title', 'article_title'), + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('LEFT', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id')) + ->where($db->quoteName('p.service_id') . ' = ' . (int) $serviceId) + ->order($db->quoteName('p.created') . ' DESC'); + + $db->setQuery($query, 0, $limit); + + return $db->loadAssocList() ?: []; + } + + /** + * Get the most cross-posted articles for a specific service. + * + * @param int $serviceId Service ID + * @param int $limit Number of articles to return + * + * @return array + */ + public function getTopArticles(int $serviceId, int $limit = 10): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.id'), + $db->quoteName('c.title'), + 'COUNT(*) AS post_count', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success_count', + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id')) + ->where($db->quoteName('p.service_id') . ' = ' . (int) $serviceId) + ->group($db->quoteName(['c.id', 'c.title'])) + ->order('post_count DESC'); + + $db->setQuery($query, 0, $limit); + + return $db->loadAssocList() ?: []; + } +} diff --git a/src/packages/com_mokojoomcross/src/Model/TemplateModel.php b/src/packages/com_mokojoomcross/src/Model/TemplateModel.php new file mode 100644 index 0000000..0db750a --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Model/TemplateModel.php @@ -0,0 +1,39 @@ + + * @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\MokoJoomCross\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\AdminModel; + +class TemplateModel extends AdminModel +{ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokojoomcross.template', + 'template', + ['control' => 'jform', 'load_data' => $loadData] + ); + + if (empty($form)) { + return false; + } + + return $form; + } + + protected function loadFormData() + { + return $this->getItem(); + } +} diff --git a/src/packages/com_mokojoomcross/src/Model/TemplatesModel.php b/src/packages/com_mokojoomcross/src/Model/TemplatesModel.php new file mode 100644 index 0000000..094e9fe --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Model/TemplatesModel.php @@ -0,0 +1,61 @@ + + * @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\MokoJoomCross\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\ListModel; + +class TemplatesModel extends ListModel +{ + public function __construct($config = []) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', 'a.id', + 'title', 'a.title', + 'service_type', 'a.service_type', + 'published', 'a.published', + 'ordering', 'a.ordering', + ]; + } + + parent::__construct($config); + } + + protected function getListQuery() + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomcross_templates', 'a')); + + $published = $this->getState('filter.published'); + + if (is_numeric($published)) { + $query->where($db->quoteName('a.published') . ' = ' . (int) $published); + } + + $serviceType = $this->getState('filter.service_type'); + + if (!empty($serviceType)) { + $query->where($db->quoteName('a.service_type') . ' = ' . $db->quote($serviceType)); + } + + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDirn = $this->state->get('list.direction', 'ASC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn)); + + return $query; + } +} diff --git a/src/packages/com_mokojoomcross/src/Service/MokoJoomCrossServiceInterface.php b/src/packages/com_mokojoomcross/src/Service/MokoJoomCrossServiceInterface.php index d27718c..cd1a369 100644 --- a/src/packages/com_mokojoomcross/src/Service/MokoJoomCrossServiceInterface.php +++ b/src/packages/com_mokojoomcross/src/Service/MokoJoomCrossServiceInterface.php @@ -70,4 +70,15 @@ interface MokoJoomCrossServiceInterface * @return bool */ public function supportsMedia(): bool; + + /** + * Get the media types this service supports. + * + * Return an array of supported types: 'image', 'video', 'gif', 'document'. + * Services that return an empty array are text-only. + * Default implementation returns ['image'] if supportsMedia() is true. + * + * @return string[] e.g. ['image', 'video', 'gif'] + */ + public function getSupportedMediaTypes(): array; } diff --git a/src/packages/com_mokojoomcross/src/Table/ServiceTable.php b/src/packages/com_mokojoomcross/src/Table/ServiceTable.php index c89fc64..300d187 100644 --- a/src/packages/com_mokojoomcross/src/Table/ServiceTable.php +++ b/src/packages/com_mokojoomcross/src/Table/ServiceTable.php @@ -13,6 +13,9 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Table; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Filter\OutputFilter; +use Joomla\CMS\Language\Text; use Joomla\CMS\Table\Table; use Joomla\Database\DatabaseDriver; @@ -22,4 +25,67 @@ class ServiceTable extends Table { parent::__construct('#__mokojoomcross_services', 'id', $db); } + + /** + * Validate the record before storing. + * + * Generates alias from title if empty, validates required fields, + * sets created/modified timestamps. + * + * @return boolean True if the record is valid + */ + public function check(): bool + { + // Title is required + if (empty($this->title)) { + $this->setError(Text::_('COM_MOKOJOOMCROSS_ERROR_TITLE_REQUIRED')); + + return false; + } + + // Service type is required + if (empty($this->service_type)) { + $this->setError(Text::_('COM_MOKOJOOMCROSS_ERROR_SERVICE_TYPE_REQUIRED')); + + return false; + } + + // Generate alias from title if empty + if (empty($this->alias)) { + $this->alias = $this->title; + } + + $this->alias = OutputFilter::stringURLSafe($this->alias); + + // Make sure alias is unique + if (empty($this->alias)) { + $this->alias = Factory::getDate()->format('Y-m-d-H-i-s'); + } + + // Set timestamps + $now = Factory::getDate()->toSql(); + + if (empty($this->created)) { + $this->created = $now; + } + + $this->modified = $now; + + // Set created_by if not set + if (empty($this->created_by)) { + $this->created_by = Factory::getApplication()->getIdentity()->id ?? 0; + } + + // Ensure credentials is valid JSON + if (empty($this->credentials)) { + $this->credentials = '{}'; + } + + // Ensure params is valid JSON + if (empty($this->params)) { + $this->params = '{}'; + } + + return true; + } } diff --git a/src/packages/com_mokojoomcross/src/Table/TemplateTable.php b/src/packages/com_mokojoomcross/src/Table/TemplateTable.php new file mode 100644 index 0000000..c10ed29 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Table/TemplateTable.php @@ -0,0 +1,25 @@ + + * @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\MokoJoomCross\Administrator\Table; + +defined('_JEXEC') or die; + +use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseDriver; + +class TemplateTable extends Table +{ + public function __construct(DatabaseDriver $db) + { + parent::__construct('#__mokojoomcross_templates', 'id', $db); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php index b4ba7e5..4d36f03 100644 --- a/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php @@ -13,21 +13,53 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Dashboard; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper; class HtmlView extends BaseHtmlView { protected $stats; protected $migrationAvailable; + protected $recentActivity; + protected $serviceBreakdown; + protected $dailyTrend; + protected $topArticles; + public $sidebar; + public $period; public function display($tpl = null): void { - $this->stats = $this->get('Stats'); + $model = $this->getModel(); + + // Read period parameter for date range filtering + $this->period = Factory::getApplication()->input->getInt('period', 30); + $validPeriods = [7, 30, 90, 0]; + + if (!in_array($this->period, $validPeriods, true)) { + $this->period = 30; + } + + // Calculate the since date based on period (0 = all time) + $since = null; + + if ($this->period > 0) { + $since = Factory::getDate('now - ' . $this->period . ' days')->toSql(); + } + + $this->stats = $this->get('Stats'); $this->migrationAvailable = $this->get('MigrationAvailable'); + $this->recentActivity = $model->getRecentActivity(10); + $this->serviceBreakdown = $model->getServiceBreakdown($since); + $this->dailyTrend = $model->getDailyTrend($this->period ?: 365); + $this->topArticles = $model->getTopArticles(5, $since); $this->addToolbar(); + MokoJoomCrossHelper::addSubmenu('dashboard'); + $this->sidebar = \Joomla\CMS\HTML\Sidebar::render(); + parent::display($tpl); } diff --git a/src/packages/com_mokojoomcross/src/View/Logs/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Logs/HtmlView.php index 1d3228b..73a8c96 100644 --- a/src/packages/com_mokojoomcross/src/View/Logs/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Logs/HtmlView.php @@ -14,6 +14,8 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Logs; defined('_JEXEC') or die; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; use Joomla\CMS\Toolbar\ToolbarHelper; class HtmlView extends BaseHtmlView @@ -21,12 +23,16 @@ class HtmlView extends BaseHtmlView protected $items; protected $pagination; protected $state; + public $filterForm; + public $activeFilters; public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -37,5 +43,14 @@ class HtmlView extends BaseHtmlView { ToolbarHelper::title('MokoJoomCross — Activity Logs', 'share-alt'); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'logs.delete', 'JTOOLBAR_DELETE'); + + // Dashboard link in toolbar + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); } } diff --git a/src/packages/com_mokojoomcross/src/View/Post/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Post/HtmlView.php new file mode 100644 index 0000000..b825f33 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Post/HtmlView.php @@ -0,0 +1,59 @@ + + * @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\MokoJoomCross\Administrator\View\Post; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $form; + protected $item; + + public function display($tpl = null): void + { + $this->form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $isNew = empty($this->item->id); + + ToolbarHelper::title( + 'MokoJoomCross — ' . ($isNew ? Text::_('COM_MOKOJOOMCROSS_NEW_POST') : Text::_('COM_MOKOJOOMCROSS_EDIT_POST')), + 'share-alt' + ); + + ToolbarHelper::apply('post.apply'); + ToolbarHelper::save('post.save'); + + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); + + ToolbarHelper::cancel('post.cancel'); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php index 79e47b3..4a9492d 100644 --- a/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php @@ -14,19 +14,27 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Posts; defined('_JEXEC') or die; 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\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper; class HtmlView extends BaseHtmlView { protected $items; protected $pagination; protected $state; + public $filterForm; + public $activeFilters; + public $sidebar; public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -36,6 +44,39 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { ToolbarHelper::title('MokoJoomCross — Post Queue', 'share-alt'); + ToolbarHelper::addNew('post.add'); + + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->standardButton('retry', 'COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED', 'posts.retryFailed') + ->icon('icon-refresh') + ->listCheck(false); + $toolbar->standardButton('purge', 'COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED', 'posts.purgePosted') + ->icon('icon-trash') + ->listCheck(false); + + $toolbar->standardButton('retry-selected', 'COM_MOKOJOOMCROSS_TOOLBAR_RETRY_SELECTED', 'posts.retrySelected') + ->icon('icon-redo') + ->listCheck(true); + $toolbar->standardButton('schedule', 'COM_MOKOJOOMCROSS_TOOLBAR_SCHEDULE', 'posts.schedule') + ->icon('icon-calendar') + ->listCheck(true); + ToolbarHelper::deleteList('', 'posts.delete', 'JTOOLBAR_DELETE'); + + // Export CSV button + $toolbar->appendButton( + 'Link', + 'download', + 'COM_MOKOJOOMCROSS_EXPORT_CSV', + Route::_('index.php?option=com_mokojoomcross&task=posts.exportCsv&format=raw', false) + ); + + // Dashboard link in toolbar + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); } } diff --git a/src/packages/com_mokojoomcross/src/View/Service/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Service/HtmlView.php new file mode 100644 index 0000000..4df8f4a --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Service/HtmlView.php @@ -0,0 +1,61 @@ + + * @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\MokoJoomCross\Administrator\View\Service; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $form; + protected $item; + + public function display($tpl = null): void + { + $this->form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $isNew = empty($this->item->id); + + ToolbarHelper::title( + 'MokoJoomCross — ' . ($isNew ? Text::_('COM_MOKOJOOMCROSS_NEW_SERVICE') : Text::_('COM_MOKOJOOMCROSS_EDIT_SERVICE')), + 'share-alt' + ); + + ToolbarHelper::apply('service.apply'); + ToolbarHelper::save('service.save'); + + // Dashboard button in toolbar + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); + + ToolbarHelper::cancel('service.cancel'); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Service/index.html b/src/packages/com_mokojoomcross/src/View/Service/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Service/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/com_mokojoomcross/src/View/ServiceStats/HtmlView.php b/src/packages/com_mokojoomcross/src/View/ServiceStats/HtmlView.php new file mode 100644 index 0000000..d0b90bb --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/ServiceStats/HtmlView.php @@ -0,0 +1,84 @@ + + * @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\MokoJoomCross\Administrator\View\ServiceStats; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +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\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper; + +/** + * Per-service analytics drill-down view. + */ +class HtmlView extends BaseHtmlView +{ + public $service; + public $postStats; + public $dailyTrend; + public $recentPosts; + public $topArticles; + public $period; + + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoJoomCross\Administrator\Model\ServiceStatsModel $model */ + $model = $this->getModel(); + + $serviceId = $model->getServiceId(); + + $this->service = $model->getService($serviceId); + + if (!$this->service) { + throw new \RuntimeException('Service not found.', 404); + } + + $this->period = Factory::getApplication()->input->getInt('period', 30); + $validPeriods = [7, 30, 90, 0]; + + if (!\in_array($this->period, $validPeriods, true)) { + $this->period = 30; + } + + $days = $this->period ?: 365; + + $this->postStats = $model->getPostStats($serviceId); + $this->dailyTrend = $model->getDailyTrend($serviceId, $days); + $this->recentPosts = $model->getRecentPosts($serviceId, 20); + $this->topArticles = $model->getTopArticles($serviceId, 10); + + $this->addToolbar(); + + MokoJoomCrossHelper::addSubmenu('servicestats'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title( + 'MokoJoomCross — ' . $this->escape($this->service->title), + 'share-alt' + ); + + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Services/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Services/HtmlView.php index 56fdc8d..255ef04 100644 --- a/src/packages/com_mokojoomcross/src/View/Services/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Services/HtmlView.php @@ -14,6 +14,8 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Services; defined('_JEXEC') or die; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; use Joomla\CMS\Toolbar\ToolbarHelper; class HtmlView extends BaseHtmlView @@ -21,12 +23,16 @@ class HtmlView extends BaseHtmlView protected $items; protected $pagination; protected $state; + public $filterForm; + public $activeFilters; public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -41,5 +47,14 @@ class HtmlView extends BaseHtmlView ToolbarHelper::publish('services.publish', 'JTOOLBAR_PUBLISH', true); ToolbarHelper::unpublish('services.unpublish', 'JTOOLBAR_UNPUBLISH', true); ToolbarHelper::deleteList('', 'services.delete', 'JTOOLBAR_DELETE'); + + // Dashboard link in toolbar + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); } } diff --git a/src/packages/com_mokojoomcross/src/View/Template/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Template/HtmlView.php new file mode 100644 index 0000000..06d6569 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Template/HtmlView.php @@ -0,0 +1,57 @@ + + * @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\MokoJoomCross\Administrator\View\Template; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $form; + protected $item; + + public function display($tpl = null): void + { + $this->form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $isNew = empty($this->item->id); + + ToolbarHelper::title( + 'MokoJoomCross — ' . ($isNew ? 'New Template' : 'Edit Template'), + 'share-alt' + ); + ToolbarHelper::apply('template.apply'); + ToolbarHelper::save('template.save'); + ToolbarHelper::cancel('template.cancel'); + + // Dashboard link in toolbar + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Template/index.html b/src/packages/com_mokojoomcross/src/View/Template/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Template/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokojoomcross/src/View/Templates/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Templates/HtmlView.php new file mode 100644 index 0000000..c98f773 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Templates/HtmlView.php @@ -0,0 +1,60 @@ + + * @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\MokoJoomCross\Administrator\View\Templates; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $items; + protected $pagination; + protected $state; + public $filterForm; + public $activeFilters; + + public function display($tpl = null): void + { + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('MokoJoomCross — Message Templates', 'share-alt'); + ToolbarHelper::addNew('template.add'); + ToolbarHelper::editList('template.edit'); + ToolbarHelper::publish('templates.publish', 'JTOOLBAR_PUBLISH', true); + ToolbarHelper::unpublish('templates.unpublish', 'JTOOLBAR_UNPUBLISH', true); + ToolbarHelper::deleteList('', 'templates.delete', 'JTOOLBAR_DELETE'); + + // Dashboard link in toolbar + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokojoomcross&view=dashboard', false) + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/View/Templates/index.html b/src/packages/com_mokojoomcross/src/View/Templates/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokojoomcross/src/View/Templates/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php index 658cc2d..d458fe1 100644 --- a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php +++ b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php @@ -11,12 +11,36 @@ defined('_JEXEC') or die; +use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper; /** @var \Joomla\Component\MokoJoomCross\Administrator\View\Dashboard\HtmlView $this */ $stats = $this->stats; +$componentParams = ComponentHelper::getParams('com_mokojoomcross'); +$queueProcessing = $componentParams->get('queue_processing', 'scheduler'); ?> + +
+ +
+
+ +
+
+ + +queued_count > 50) : ?> +
+ +
+
+ queued_count); ?> +
+
+ +
@@ -54,6 +78,71 @@ $stats = $this->stats;
+ + dailyTrend)) : ?> +
+
+
+
+ + + +
+
+
+ +
+
+ + + migrationAvailable) : ?>

@@ -64,6 +153,112 @@ $stats = $this->stats;
+ + + serviceBreakdown)) : ?> +
+
+
+
+
+ + + + + + + + + + + + + serviceBreakdown as $row) : + $rate = $row['total'] > 0 ? round(($row['posted'] / $row['total']) * 100) : 0; + $rateClass = $rate >= 80 ? 'text-success' : ($rate >= 50 ? 'text-warning' : 'text-danger'); + ?> + + + + + + + + + + +
+ + + + + %
+
+
+ + + + topArticles)) : ?> +
+
+
+
+
+
+ topArticles as $row) : ?> +
+ + + + / + + +
+ +
+
+
+ + + +
+
+
+
+
+ recentActivity)) : ?> +

+ +
+ recentActivity as $entry) : + $levelClass = match ($entry->level) { + 'error' => 'text-danger', + 'warning' => 'text-warning', + default => 'text-muted', + }; + $levelIcon = match ($entry->level) { + 'error' => 'icon-times-circle', + 'warning' => 'icon-exclamation-triangle', + default => 'icon-info-circle', + }; + ?> +
+
+ + + message, 0, 120)); ?> + + created, 'Y-m-d H:i'); ?> +
+ service_title) : ?> + service_title); ?> + +
+ +
+ +
+
@@ -79,6 +274,10 @@ $stats = $this->stats; class="list-group-item list-group-item-action"> + + + diff --git a/src/packages/com_mokojoomcross/tmpl/logs/default.php b/src/packages/com_mokojoomcross/tmpl/logs/default.php new file mode 100644 index 0000000..e0c7067 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/logs/default.php @@ -0,0 +1,110 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Logs\HtmlView $this */ + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); + +$levelBadges = [ + 'info' => 'bg-info', + 'warning' => 'bg-warning text-dark', + 'error' => 'bg-danger', +]; +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + items as $i => $item) : + $badgeClass = $levelBadges[$item->level] ?? 'bg-secondary'; + ?> + + + + + + + + + + +
+ + + + + + + + + + + +
+ id, false, 'cid', 'cb', ''); ?> + + + escape(ucfirst($item->level)); ?> + + + escape($item->message); ?> + context) && $item->context !== '{}') : ?> +
escape(mb_substr($item->context, 0, 200)); ?> + +
+ escape($item->service_title ?? '—'); ?> + + created, 'Y-m-d H:i:s'); ?> + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokojoomcross/tmpl/post/edit.php b/src/packages/com_mokojoomcross/tmpl/post/edit.php new file mode 100644 index 0000000..a459ab8 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/post/edit.php @@ -0,0 +1,79 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Post\HtmlView $this */ + +HTMLHelper::_('behavior.formvalidator'); +HTMLHelper::_('behavior.keepalive'); + +$postId = (int) ($this->item->id ?? 0); +$isNew = empty($postId); +?> +
+ +
+
+
+

+ + +
+ + +
+ + + form->renderFieldset('details'); ?> +
+ +
+ +
+
+
+ + +
+
+
+ form->renderFieldset('readonly'); ?> +
+
+ + item->status ?? ''; + if (in_array($status, ['failed', 'posted'])) : ?> +
+
+

+ +
+
+ + +
+
+
+ + + +
diff --git a/src/packages/com_mokojoomcross/tmpl/posts/default.php b/src/packages/com_mokojoomcross/tmpl/posts/default.php new file mode 100644 index 0000000..b617002 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/posts/default.php @@ -0,0 +1,137 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Posts\HtmlView $this */ + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); + +$statusBadges = [ + 'queued' => 'bg-warning text-dark', + 'posting' => 'bg-info', + 'posted' => 'bg-success', + 'failed' => 'bg-danger', + 'scheduled' => 'bg-secondary', +]; +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + + + items as $i => $item) : + $badgeClass = $statusBadges[$item->status] ?? 'bg-secondary'; + ?> + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->article_title ?? ''); ?> + + + escape(ucfirst($item->status)); ?> + + status === 'failed' && !empty($item->error_message)) : ?> +
escape(mb_substr($item->error_message, 0, 80)); ?> + + retry_count > 0) : ?> +
Retries: retry_count; ?> + +
+ + escape($item->article_title ?? 'Article #' . $item->article_id); ?> + + scheduled_at)) : ?> +
scheduled_at, 'Y-m-d H:i'); ?> + +
+ escape($item->service_title ?? ''); ?> +
service_type ?? ''); ?> escape($item->service_type ?? ''); ?> +
+ escape(mb_substr($item->message ?? '', 0, 100)); ?> + platform_post_id)) : ?> +
ID: escape($item->platform_post_id); ?> + +
+ posted_at ? HTMLHelper::_('date', $item->posted_at, 'Y-m-d H:i') : '—'; ?> + + created, 'Y-m-d H:i'); ?> + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokojoomcross/tmpl/service/edit.php b/src/packages/com_mokojoomcross/tmpl/service/edit.php new file mode 100644 index 0000000..3c551b0 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/service/edit.php @@ -0,0 +1,216 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Service\HtmlView $this */ + +HTMLHelper::_('behavior.formvalidator'); +HTMLHelper::_('behavior.keepalive'); + +$serviceType = $this->item->service_type ?? ''; +$serviceId = (int) ($this->item->id ?? 0); + +// Services that support OAuth authorize flow +$oauthServices = ['facebook', 'linkedin', 'twitter', 'threads', 'pinterest', 'tumblr', 'tiktok', 'constantcontact', 'blogger', 'googlebusiness']; +$showAuthorize = in_array($serviceType, $oauthServices) && $serviceId > 0; + +// Map service types to KB article aliases on mokoconsulting.tech +$helpArticles = [ + 'facebook' => 'service-facebook-mokojoomcross', + 'twitter' => 'service-twitter-mokojoomcross', + 'linkedin' => 'service-linkedin-mokojoomcross', + 'mastodon' => 'service-mastodon-mokojoomcross', + 'bluesky' => 'service-bluesky-mokojoomcross', + 'threads' => 'service-threads-mokojoomcross', + 'pinterest' => 'service-pinterest-mokojoomcross', + 'reddit' => 'service-reddit-mokojoomcross', + 'tumblr' => 'service-tumblr-mokojoomcross', + 'tiktok' => 'service-tiktok-mokojoomcross', + 'nostr' => 'service-nostr-mokojoomcross', + 'activitypub' => 'service-activitypub-mokojoomcross', + 'telegram' => 'service-telegram-mokojoomcross', + 'discord' => 'service-discord-mokojoomcross', + 'slack' => 'service-slack-mokojoomcross', + 'teams' => 'service-teams-mokojoomcross', + 'googlechat' => 'service-googlechat-mokojoomcross', + 'whatsapp' => 'service-whatsapp-mokojoomcross', + 'matrix' => 'service-matrix-mokojoomcross', + 'ntfy' => 'service-ntfy-mokojoomcross', + 'mailchimp' => 'service-mailchimp-mokojoomcross', + 'sendgrid' => 'service-sendgrid-mokojoomcross', + 'brevo' => 'service-brevo-mokojoomcross', + 'convertkit' => 'service-convertkit-mokojoomcross', + 'constantcontact' => 'service-constantcontact-mokojoomcross', + 'medium' => 'service-medium-mokojoomcross', + 'wordpress' => 'service-wordpress-mokojoomcross', + 'devto' => 'service-devto-mokojoomcross', + 'ghost' => 'service-ghost-mokojoomcross', + 'hashnode' => 'service-hashnode-mokojoomcross', + 'blogger' => 'service-blogger-mokojoomcross', + 'googlebusiness' => 'service-googlebusiness-mokojoomcross', + 'webhook' => 'service-webhook-mokojoomcross', + 'rssfeed' => 'service-rssfeed-mokojoomcross', +]; +$helpAlias = $helpArticles[$serviceType] ?? ''; +?> +
+ +
+
+ + +
+
+
+
+ + +
+
+
+

+
    +
  1. +
  2. +
  3. +
  4. +
+
+
+ + +
+ +
+ + + +
+
+
+ + +
+
+
+

+
+
+ + + 0 && !empty($serviceType)) : ?> +
+
+
+ + +
+
+
+

+ + +
+
+ + +
+
+
+ + + +
diff --git a/src/packages/com_mokojoomcross/tmpl/service/index.html b/src/packages/com_mokojoomcross/tmpl/service/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/service/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/com_mokojoomcross/tmpl/services/default.php b/src/packages/com_mokojoomcross/tmpl/services/default.php new file mode 100644 index 0000000..d8b57eb --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/services/default.php @@ -0,0 +1,109 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Services\HtmlView $this */ + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); + +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + items as $i => $item) : + $credentials = json_decode($item->credentials ?: '{}', true) ?: []; + $mode = $credentials['mode'] ?? 'custom'; + ?> + + + + + + + + + + +
+ + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->title); ?> + + published, $i, 'services.', true); ?> + + + escape($item->title); ?> + + + service_type); ?> + escape(ucfirst($item->service_type)); ?> + + + Default Bot + + Custom + + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokojoomcross/tmpl/services/default_service.php b/src/packages/com_mokojoomcross/tmpl/services/default_service.php new file mode 100644 index 0000000..f50e664 --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/services/default_service.php @@ -0,0 +1,44 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\CMS\Form\Form $this->form */ + +HTMLHelper::_('behavior.formvalidator'); +HTMLHelper::_('behavior.keepalive'); +?> +
+ +
+
+
+ form->renderFieldset('details'); ?> +
+
+
+
+

+ form->renderFieldset('credentials'); ?> +
+
+
+
+
+ + + +
diff --git a/src/packages/com_mokojoomcross/tmpl/servicestats/default.php b/src/packages/com_mokojoomcross/tmpl/servicestats/default.php new file mode 100644 index 0000000..78ec4cc --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/servicestats/default.php @@ -0,0 +1,219 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\ServiceStats\HtmlView $this */ + +$service = $this->service; +$stats = $this->postStats; +$rate = $stats->total > 0 ? round(($stats->posted / $stats->total) * 100) : 0; +$rateClass = $rate >= 80 ? 'text-success' : ($rate >= 50 ? 'text-warning' : 'text-danger'); + +$statusBadges = [ + 'queued' => 'bg-warning text-dark', + 'posting' => 'bg-info', + 'posted' => 'bg-success', + 'failed' => 'bg-danger', + 'scheduled' => 'bg-secondary', +]; +?> + + +
+ service_type, 'fs-3 me-2'); ?> +

escape($service->title); ?>

+ escape(ucfirst($service->service_type)); ?> +
+ + +
+
+
+
+
+

total; ?>

+
+
+
+
+
+
+
+

posted; ?>

+
+
+
+
+
+
+
+

failed; ?>

+
+
+
+
+
+
+
+

%

+
+
+
+
+ + +dailyTrend)) : ?> +
+
+
+
+ + + + +
+
+
+ +
+
+ + + + + +
+
+
+
+
+ recentPosts)) : ?> +

+ + + + + + + + + + + + recentPosts as $post) : + $badgeClass = $statusBadges[$post['status']] ?? 'bg-secondary'; + ?> + + + + + + + + +
+ + escape(ucfirst($post['status'])); ?> + + 0) : ?> +
Retries: + +
+ + escape($post['article_title'] ?? 'Article #' . $post['id']); ?> + + + + + + escape(mb_substr($post['error_message'], 0, 100)); ?> + + — + +
+ +
+
+ + +topArticles)) : ?> +
+
+
+
+
+
+ topArticles as $row) : ?> +
+ + + + / + + +
+ +
+
+
+ diff --git a/src/packages/com_mokojoomcross/tmpl/template/edit.php b/src/packages/com_mokojoomcross/tmpl/template/edit.php new file mode 100644 index 0000000..67841cc --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/template/edit.php @@ -0,0 +1,114 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Template\HtmlView $this */ + +HTMLHelper::_('behavior.formvalidator'); +HTMLHelper::_('behavior.keepalive'); +?> +
+ +
+
+
+ form->renderFieldset('details'); ?> +
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
{title}
{url}
{introtext}
{fulltext}
{image}
{category}
{author}
{date}
{tags}
{hashtags}
{field:xxx}
+
+
+
+
+
+ + + +
+ + diff --git a/src/packages/com_mokojoomcross/tmpl/template/index.html b/src/packages/com_mokojoomcross/tmpl/template/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/template/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokojoomcross/tmpl/templates/default.php b/src/packages/com_mokojoomcross/tmpl/templates/default.php new file mode 100644 index 0000000..d2abe0c --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/templates/default.php @@ -0,0 +1,103 @@ + + * @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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Templates\HtmlView $this */ + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + items as $i => $item) : ?> + + + + + + + + + + +
+ + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->title); ?> + + published, $i, 'templates.', true); ?> + + + escape($item->title); ?> + + + service_type === 'default') : ?> + Default + + escape(ucfirst($item->service_type)); ?> + + + escape(mb_substr($item->template_body, 0, 80)); ?> + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokojoomcross/tmpl/templates/index.html b/src/packages/com_mokojoomcross/tmpl/templates/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokojoomcross/tmpl/templates/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_content_mokojoomcross/language/en-GB/plg_content_mokojoomcross.ini b/src/packages/plg_content_mokojoomcross/language/en-GB/plg_content_mokojoomcross.ini index b3bd1ee..01b6477 100644 --- a/src/packages/plg_content_mokojoomcross/language/en-GB/plg_content_mokojoomcross.ini +++ b/src/packages/plg_content_mokojoomcross/language/en-GB/plg_content_mokojoomcross.ini @@ -1,2 +1,13 @@ PLG_CONTENT_MOKOJOOMCROSS="Content - MokoJoomCross" -PLG_CONTENT_MOKOJOOMCROSS_DESCRIPTION="Adds cross-post status badges to articles in the admin backend." +PLG_CONTENT_MOKOJOOMCROSS_DESCRIPTION="Adds cross-post status badges and per-article service selection to the article editor." + +PLG_CONTENT_MOKOJOOMCROSS_FIELDSET_CROSSPOST="Cross-Posting" +PLG_CONTENT_MOKOJOOMCROSS_SKIP="Skip Cross-Posting" +PLG_CONTENT_MOKOJOOMCROSS_SKIP_DESC="Skip all cross-posting for this article." +PLG_CONTENT_MOKOJOOMCROSS_SERVICES="Post to Services" +PLG_CONTENT_MOKOJOOMCROSS_SERVICES_DESC="Select which services to cross-post to. Leave all unchecked to post to all enabled services." +PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN="Evergreen Content" +PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_DESC="Automatically re-share this article on a recurring schedule. Great for high-value content that stays relevant." +PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL="Re-share Interval (days)" +PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL_DESC="How many days to wait between automatic re-shares. Default: 30 days." +PLG_CONTENT_MOKOJOOMCROSS_HISTORY="Cross-Post History" diff --git a/src/packages/plg_content_mokojoomcross/mokojoomcross.xml b/src/packages/plg_content_mokojoomcross/mokojoomcross.xml index fab655b..c4fe905 100644 --- a/src/packages/plg_content_mokojoomcross/mokojoomcross.xml +++ b/src/packages/plg_content_mokojoomcross/mokojoomcross.xml @@ -1,7 +1,7 @@ Content - MokoJoomCross - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_content_mokojoomcross/src/Extension/MokoJoomCrossContent.php b/src/packages/plg_content_mokojoomcross/src/Extension/MokoJoomCrossContent.php index cd76c8b..31241db 100644 --- a/src/packages/plg_content_mokojoomcross/src/Extension/MokoJoomCrossContent.php +++ b/src/packages/plg_content_mokojoomcross/src/Extension/MokoJoomCrossContent.php @@ -13,34 +13,214 @@ namespace Joomla\Plugin\Content\MokoJoomCross\Extension; defined('_JEXEC') or die; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Event\Model\PrepareFormEvent; use Joomla\CMS\Factory; +use Joomla\CMS\Form\Form; +use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher; use Joomla\Event\SubscriberInterface; /** - * Content plugin that adds cross-post status badges to article views. + * Content plugin that: + * 1. Adds cross-post status badges to article views in admin + * 2. Injects service selection checkboxes into the article editor (#19) */ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface { public static function getSubscribedEvents(): array { return [ - 'onContentBeforeDisplay' => 'onContentBeforeDisplay', + 'onContentBeforeDisplay' => 'onContentBeforeDisplay', + 'onContentPrepareForm' => 'onContentPrepareForm', + 'onContentAfterSave' => 'onContentAfterSave', + 'onContentChangeState' => 'onContentChangeState', ]; } /** - * Add cross-post status indicator before article content in admin. + * Inject cross-post service selection fields into article edit form. * - * @param string $context The context - * @param object $article The article - * @param object $params The article params - * @param int $page The page number - * - * @return string HTML to prepend to the article + * Adds a "Cross-Posting" fieldset to the article attribs tab with: + * - Checkbox list of all enabled services + * - Skip cross-posting toggle */ - public function onContentBeforeDisplay(string $context, &$article, &$params, int $page = 0): string + /** + * Joomla 5/6 compatible — accepts both PrepareFormEvent and legacy Form signature. + */ + public function onContentPrepareForm($event): void { + // Joomla 5+ passes PrepareFormEvent; extract the Form from it + if ($event instanceof PrepareFormEvent) { + $form = $event->getForm(); + } elseif ($event instanceof Form) { + $form = $event; + } else { + return; + } + + if ($form->getName() !== 'com_content.article') { + return; + } + + $app = $this->getApplication(); + + if (!$app->isClient('administrator')) { + return; + } + + $db = Factory::getDbo(); + + // Load enabled services for the checkbox list + $query = $db->getQuery(true) + ->select('id, title, service_type') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + $services = $db->loadObjectList(); + + if (empty($services)) { + return; + } + + // Build dynamic XML form for the attribs fieldset + $options = ''; + + foreach ($services as $svc) { + $label = htmlspecialchars($svc->title . ' (' . ucfirst($svc->service_type) . ')', ENT_XML1); + $options .= ''; + } + + $xml = << +
+ +
+ + + + + + {$options} + + + + + + +
+
+
+XML; + + $form->load($xml); + + // Cross-post history panel for existing articles + $articleId = Factory::getApplication()->input->getInt('id', 0); + + if ($articleId > 0) { + $query = $db->getQuery(true) + ->select('p.status, p.posted_at, p.error_message, s.title AS service_title, s.service_type') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's') . ' ON s.id = p.service_id') + ->where($db->quoteName('p.article_id') . ' = ' . $articleId) + ->order('p.created DESC'); + $db->setQuery($query, 0, 10); + $history = $db->loadObjectList(); + + if (!empty($history)) { + $historyHtml = '
'; + + foreach ($history as $post) { + $badgeClass = match ($post->status) { + 'posted' => 'bg-success', + 'failed' => 'bg-danger', + 'queued' => 'bg-warning', + default => 'bg-secondary', + }; + $historyHtml .= '
' + . '' . ucfirst($post->status) . '' + . '' . htmlspecialchars($post->service_title ?? '') . ''; + + if ($post->posted_at) { + $historyHtml .= ' ' . HTMLHelper::_('date', $post->posted_at, 'Y-m-d H:i') . ''; + } + + if ($post->status === 'failed' && $post->error_message) { + $historyHtml .= '
' . htmlspecialchars(mb_substr($post->error_message, 0, 60)) . ''; + } + + $historyHtml .= '
'; + } + + $historyHtml .= '
'; + + // Add the note field first with an empty description, then set the + // description via setFieldAttribute() to avoid double-escaping. + // Putting raw HTML into an XML attribute via htmlspecialchars() causes + // Joomla's note field renderer to display escaped tags since it outputs + // the description as raw HTML. + $historyXml = ' +
+ +
'; + $form->load($historyXml); + $form->setFieldAttribute('mokojoomcross_history', 'description', $historyHtml, 'attribs'); + } + } + } + + /** + * Add cross-post status badges before article content in admin. + * + * Joomla 5/6 compatible — accepts both BeforeDisplayEvent and legacy parameters. + */ + public function onContentBeforeDisplay($event): string + { + // Joomla 5/6 compatibility + if ($event instanceof \Joomla\CMS\Event\Content\BeforeDisplayEvent) { + $context = $event->getContext(); + $article = $event->getItem(); + } elseif (is_string($event)) { + $context = $event; + $article = func_get_arg(1); + } else { + return ''; + } + if ($context !== 'com_content.article') { return ''; } @@ -82,4 +262,100 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface return '
' . $badges . '
'; } + + /** + * Dispatch cross-post when an article is saved and published. + * + * Joomla 5/6 compatible — accepts both AfterSaveEvent and legacy parameters. + */ + public function onContentAfterSave($event): void + { + // Joomla 5/6 compatibility + if ($event instanceof \Joomla\CMS\Event\Content\AfterSaveEvent) { + $context = $event->getContext(); + $article = $event->getItem(); + $isNew = $event->getIsNew(); + } else { + $context = $event; + $article = func_get_arg(1); + $isNew = func_get_arg(2); + } + + if ($context !== 'com_content.article') { + return; + } + + if ((int) ($article->state ?? 0) !== 1) { + return; + } + + $params = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$params->get('auto_post_on_publish', 1)) { + return; + } + + if ($params->get('post_on_first_publish_only', 0) && !$isNew) { + return; + } + + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $url .= '&catid=' . $article->catid; + } + + CrossPostDispatcher::dispatch($article, $url, 'com_content.article'); + } + + /** + * Dispatch cross-post when article state changes to published. + * + * Joomla 5/6 compatible — accepts both ContentChangeStateEvent and legacy parameters. + */ + public function onContentChangeState($event): void + { + if ($event instanceof \Joomla\CMS\Event\Content\ContentChangeStateEvent) { + $context = $event->getContext(); + $pks = $event->getPks(); + $value = $event->getValue(); + } else { + $context = $event; + $pks = func_get_arg(1); + $value = func_get_arg(2); + } + + if ($context !== 'com_content.article' || $value !== 1) { + return; + } + + $params = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$params->get('auto_post_on_publish', 1)) { + return; + } + + $db = Factory::getDbo(); + + foreach ($pks as $pk) { + $query = $db->getQuery(true) + ->select('*') + ->from('#__content') + ->where('id = ' . (int) $pk); + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + continue; + } + + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $url .= '&catid=' . $article->catid; + } + + CrossPostDispatcher::dispatch($article, $url, 'com_content.article'); + } + } } diff --git a/src/packages/plg_mokojoomcross_activitypub/activitypub.php b/src/packages/plg_mokojoomcross_activitypub/activitypub.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/activitypub.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_activitypub/activitypub.xml b/src/packages/plg_mokojoomcross_activitypub/activitypub.xml new file mode 100644 index 0000000..3db6bdd --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/activitypub.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - ActivityPub (Fediverse) + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Activitypub + + + activitypub.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_activitypub.ini + language/en-GB/plg_mokojoomcross_activitypub.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_activitypub/index.html b/src/packages/plg_mokojoomcross_activitypub/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_activitypub/language/en-GB/index.html b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.ini b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.ini new file mode 100644 index 0000000..5aef49d --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_ACTIVITYPUB="MokoJoomCross - ActivityPub (Fediverse)" +PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION="Cross-post Joomla articles to ActivityPub (Fediverse)." diff --git a/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.sys.ini b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.sys.ini new file mode 100644 index 0000000..5aef49d --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/language/en-GB/plg_mokojoomcross_activitypub.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_ACTIVITYPUB="MokoJoomCross - ActivityPub (Fediverse)" +PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION="Cross-post Joomla articles to ActivityPub (Fediverse)." diff --git a/src/packages/plg_mokojoomcross_activitypub/language/index.html b/src/packages/plg_mokojoomcross_activitypub/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_activitypub/services/index.html b/src/packages/plg_mokojoomcross_activitypub/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_activitypub/services/provider.php b/src/packages/plg_mokojoomcross_activitypub/services/provider.php new file mode 100644 index 0000000..ecee5bc --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Activitypub\Extension\ActivitypubService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new ActivitypubService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'activitypub') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_activitypub/src/Extension/ActivitypubService.php b/src/packages/plg_mokojoomcross_activitypub/src/Extension/ActivitypubService.php new file mode 100644 index 0000000..8ae514d --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/src/Extension/ActivitypubService.php @@ -0,0 +1,112 @@ + + * @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\MokoJoomCross\Activitypub\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * ActivityPub (Fediverse) service plugin for MokoJoomCross. + * + * Works with Mastodon-compatible APIs (Pleroma, Akkoma, Misskey, Pixelfed). + * Uses the /api/v1/statuses endpoint with Bearer token auth. + */ +class ActivitypubService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'activitypub'; } + public function getServiceName(): string { return 'ActivityPub (Fediverse)'; } + public function getMaxLength(): int { return 500; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $instanceUrl = rtrim($credentials['instance_url'] ?? '', '/'); + $token = $credentials['access_token'] ?? ''; + + if (empty($instanceUrl) || empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing instance URL or access token.']]; + } + + $apiUrl = $instanceUrl . '/api/v1/statuses'; + $payload = json_encode(['status' => mb_substr($message, 0, 500)]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $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 + { + $instanceUrl = rtrim($credentials['instance_url'] ?? '', '/'); + $token = $credentials['access_token'] ?? ''; + + if (empty($instanceUrl) || empty($token)) { + return ['valid' => false, 'message' => 'Instance URL and access token are required.', 'account_name' => '']; + } + + $ch = curl_init($instanceUrl . '/api/v1/accounts/verify_credentials'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['username'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $data['username'] . '@' . parse_url($instanceUrl, PHP_URL_HOST)]; + } + + return ['valid' => false, 'message' => $data['error'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/src/packages/plg_mokojoomcross_activitypub/src/Extension/index.html b/src/packages/plg_mokojoomcross_activitypub/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_activitypub/src/index.html b/src/packages/plg_mokojoomcross_activitypub/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_activitypub/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/blogger.php b/src/packages/plg_mokojoomcross_blogger/blogger.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/blogger.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_blogger/blogger.xml b/src/packages/plg_mokojoomcross_blogger/blogger.xml new file mode 100644 index 0000000..0888fc1 --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/blogger.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Google Blogger + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Blogger + + + blogger.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_blogger.ini + language/en-GB/plg_mokojoomcross_blogger.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_blogger/index.html b/src/packages/plg_mokojoomcross_blogger/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/language/en-GB/index.html b/src/packages/plg_mokojoomcross_blogger/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.ini b/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.ini new file mode 100644 index 0000000..9036ee0 --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_BLOGGER="MokoJoomCross - Google Blogger" +PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION="Cross-post Joomla articles to Google Blogger." diff --git a/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.sys.ini b/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.sys.ini new file mode 100644 index 0000000..9036ee0 --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/language/en-GB/plg_mokojoomcross_blogger.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_BLOGGER="MokoJoomCross - Google Blogger" +PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION="Cross-post Joomla articles to Google Blogger." diff --git a/src/packages/plg_mokojoomcross_blogger/language/index.html b/src/packages/plg_mokojoomcross_blogger/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/services/index.html b/src/packages/plg_mokojoomcross_blogger/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/services/provider.php b/src/packages/plg_mokojoomcross_blogger/services/provider.php new file mode 100644 index 0000000..6b4b25d --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Blogger\Extension\BloggerService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new BloggerService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'blogger') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_blogger/src/Extension/BloggerService.php b/src/packages/plg_mokojoomcross_blogger/src/Extension/BloggerService.php new file mode 100644 index 0000000..0794448 --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/src/Extension/BloggerService.php @@ -0,0 +1,115 @@ + + * @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\MokoJoomCross\Blogger\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Google Blogger service plugin for MokoJoomCross. + * + * Uses the Blogger API v3 with OAuth Bearer token. + */ +class BloggerService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'blogger'; } + public function getServiceName(): string { return 'Google Blogger'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + $blogId = $credentials['blog_id'] ?? ''; + + if (empty($token) || empty($blogId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or blog ID.']]; + } + + $apiUrl = 'https://www.googleapis.com/blogger/v3/blogs/' . urlencode($blogId) . '/posts'; + $payload = json_encode([ + 'kind' => 'blogger#post', + 'title' => mb_substr(strip_tags($message), 0, 150), + 'content' => $message, + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $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 = $credentials['access_token'] ?? ''; + $blogId = $credentials['blog_id'] ?? ''; + + if (empty($token) || empty($blogId)) { + return ['valid' => false, 'message' => 'Access token and blog ID are required.', 'account_name' => '']; + } + + $ch = curl_init('https://www.googleapis.com/blogger/v3/blogs/' . urlencode($blogId)); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['name'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['name']]; + } + + return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_blogger/src/Extension/index.html b/src/packages/plg_mokojoomcross_blogger/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_blogger/src/index.html b/src/packages/plg_mokojoomcross_blogger/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_blogger/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_bluesky/bluesky.xml b/src/packages/plg_mokojoomcross_bluesky/bluesky.xml index 7b61a61..88fd371 100644 --- a/src/packages/plg_mokojoomcross_bluesky/bluesky.xml +++ b/src/packages/plg_mokojoomcross_bluesky/bluesky.xml @@ -1,7 +1,7 @@ MokoJoomCross - Bluesky - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,29 @@ language/en-GB/plg_mokojoomcross_bluesky.ini language/en-GB/plg_mokojoomcross_bluesky.sys.ini + + + +
+ + + + + +
+
+
diff --git a/src/packages/plg_mokojoomcross_bluesky/language/en-GB/plg_mokojoomcross_bluesky.ini b/src/packages/plg_mokojoomcross_bluesky/language/en-GB/plg_mokojoomcross_bluesky.ini index fc9b753..f2c8d63 100644 --- a/src/packages/plg_mokojoomcross_bluesky/language/en-GB/plg_mokojoomcross_bluesky.ini +++ b/src/packages/plg_mokojoomcross_bluesky/language/en-GB/plg_mokojoomcross_bluesky.ini @@ -1,2 +1,7 @@ PLG_MOKOJOOMCROSS_BLUESKY="MokoJoomCross - Bluesky" PLG_MOKOJOOMCROSS_BLUESKY_DESCRIPTION="Cross-post Joomla articles to Bluesky." +PLG_MOKOJOOMCROSS_BLUESKY_FIELDSET_DEFAULTS="Bluesky Defaults" +PLG_MOKOJOOMCROSS_BLUESKY_DEFAULT_PDS_URL="Default PDS URL" +PLG_MOKOJOOMCROSS_BLUESKY_DEFAULT_PDS_URL_DESC="Default Bluesky PDS URL (e.g. https://bsky.social)." +PLG_MOKOJOOMCROSS_BLUESKY_AUTO_LINK_CARD="Auto Link Card" +PLG_MOKOJOOMCROSS_BLUESKY_AUTO_LINK_CARD_DESC="Automatically detect URLs and create link cards in posts." diff --git a/src/packages/plg_mokojoomcross_bluesky/services/provider.php b/src/packages/plg_mokojoomcross_bluesky/services/provider.php index e441098..960d302 100644 --- a/src/packages/plg_mokojoomcross_bluesky/services/provider.php +++ b/src/packages/plg_mokojoomcross_bluesky/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Bluesky\Extension\BlueskyService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_bluesky/src/Extension/BlueskyService.php b/src/packages/plg_mokojoomcross_bluesky/src/Extension/BlueskyService.php index b9d4711..5076372 100644 --- a/src/packages/plg_mokojoomcross_bluesky/src/Extension/BlueskyService.php +++ b/src/packages/plg_mokojoomcross_bluesky/src/Extension/BlueskyService.php @@ -127,4 +127,9 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoJoomC return json_decode($response, true) ?: []; } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } } diff --git a/src/packages/plg_mokojoomcross_brevo/brevo.php b/src/packages/plg_mokojoomcross_brevo/brevo.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/brevo.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_brevo/brevo.xml b/src/packages/plg_mokojoomcross_brevo/brevo.xml new file mode 100644 index 0000000..945697c --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/brevo.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Brevo (Sendinblue) + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Brevo + + + brevo.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_brevo.ini + language/en-GB/plg_mokojoomcross_brevo.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_brevo/index.html b/src/packages/plg_mokojoomcross_brevo/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_brevo/language/en-GB/index.html b/src/packages/plg_mokojoomcross_brevo/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.ini b/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.ini new file mode 100644 index 0000000..b67b29b --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_BREVO="MokoJoomCross - Brevo (Sendinblue)" +PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION="Cross-post Joomla articles to Brevo (Sendinblue)." diff --git a/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.sys.ini b/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.sys.ini new file mode 100644 index 0000000..b67b29b --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/language/en-GB/plg_mokojoomcross_brevo.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_BREVO="MokoJoomCross - Brevo (Sendinblue)" +PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION="Cross-post Joomla articles to Brevo (Sendinblue)." diff --git a/src/packages/plg_mokojoomcross_brevo/language/index.html b/src/packages/plg_mokojoomcross_brevo/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_brevo/services/index.html b/src/packages/plg_mokojoomcross_brevo/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_brevo/services/provider.php b/src/packages/plg_mokojoomcross_brevo/services/provider.php new file mode 100644 index 0000000..0586f45 --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Brevo\Extension\BrevoService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new BrevoService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'brevo') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_brevo/src/Extension/BrevoService.php b/src/packages/plg_mokojoomcross_brevo/src/Extension/BrevoService.php new file mode 100644 index 0000000..e3ed95b --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/src/Extension/BrevoService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Brevo\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Brevo (Sendinblue) service plugin for MokoJoomCross. + * + * API: https://api.brevo.com/v3/emailCampaigns + */ +class BrevoService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'brevo'; } + public function getServiceName(): string { return 'Brevo (Sendinblue)'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['api_key'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Brevo (Sendinblue)']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_brevo/src/Extension/index.html b/src/packages/plg_mokojoomcross_brevo/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_brevo/src/index.html b/src/packages/plg_mokojoomcross_brevo/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_brevo/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/constantcontact.php b/src/packages/plg_mokojoomcross_constantcontact/constantcontact.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/constantcontact.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_constantcontact/constantcontact.xml b/src/packages/plg_mokojoomcross_constantcontact/constantcontact.xml new file mode 100644 index 0000000..9cf1bd6 --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/constantcontact.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Constant Contact + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Constantcontact + + + constantcontact.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_constantcontact.ini + language/en-GB/plg_mokojoomcross_constantcontact.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_constantcontact/index.html b/src/packages/plg_mokojoomcross_constantcontact/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/index.html b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.ini b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.ini new file mode 100644 index 0000000..c5580f5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_CONSTANTCONTACT="MokoJoomCross - Constant Contact" +PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION="Cross-post Joomla articles to Constant Contact." diff --git a/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.sys.ini b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.sys.ini new file mode 100644 index 0000000..c5580f5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/language/en-GB/plg_mokojoomcross_constantcontact.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_CONSTANTCONTACT="MokoJoomCross - Constant Contact" +PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION="Cross-post Joomla articles to Constant Contact." diff --git a/src/packages/plg_mokojoomcross_constantcontact/language/index.html b/src/packages/plg_mokojoomcross_constantcontact/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/services/index.html b/src/packages/plg_mokojoomcross_constantcontact/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/services/provider.php b/src/packages/plg_mokojoomcross_constantcontact/services/provider.php new file mode 100644 index 0000000..5b423f3 --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Constantcontact\Extension\ConstantcontactService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new ConstantcontactService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'constantcontact') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_constantcontact/src/Extension/ConstantcontactService.php b/src/packages/plg_mokojoomcross_constantcontact/src/Extension/ConstantcontactService.php new file mode 100644 index 0000000..162f5ac --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/src/Extension/ConstantcontactService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Constantcontact\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Constant Contact service plugin for MokoJoomCross. + * + * API: https://api.cc.email/v3/emails + */ +class ConstantcontactService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'constantcontact'; } + public function getServiceName(): string { return 'Constant Contact'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Constant Contact']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_constantcontact/src/Extension/index.html b/src/packages/plg_mokojoomcross_constantcontact/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_constantcontact/src/index.html b/src/packages/plg_mokojoomcross_constantcontact/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_constantcontact/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/convertkit.php b/src/packages/plg_mokojoomcross_convertkit/convertkit.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/convertkit.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_convertkit/convertkit.xml b/src/packages/plg_mokojoomcross_convertkit/convertkit.xml new file mode 100644 index 0000000..9824c56 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/convertkit.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - ConvertKit + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_CONVERTKIT_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Convertkit + + + convertkit.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_convertkit.ini + language/en-GB/plg_mokojoomcross_convertkit.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_convertkit/index.html b/src/packages/plg_mokojoomcross_convertkit/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/language/en-GB/index.html b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.ini b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.ini new file mode 100644 index 0000000..641c002 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_CONVERTKIT="MokoJoomCross - ConvertKit" +PLG_MOKOJOOMCROSS_CONVERTKIT_DESCRIPTION="Cross-post Joomla articles to ConvertKit." diff --git a/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.sys.ini b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.sys.ini new file mode 100644 index 0000000..641c002 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/language/en-GB/plg_mokojoomcross_convertkit.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_CONVERTKIT="MokoJoomCross - ConvertKit" +PLG_MOKOJOOMCROSS_CONVERTKIT_DESCRIPTION="Cross-post Joomla articles to ConvertKit." diff --git a/src/packages/plg_mokojoomcross_convertkit/language/index.html b/src/packages/plg_mokojoomcross_convertkit/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/services/index.html b/src/packages/plg_mokojoomcross_convertkit/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/services/provider.php b/src/packages/plg_mokojoomcross_convertkit/services/provider.php new file mode 100644 index 0000000..fd6fb04 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Convertkit\Extension\ConvertkitService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new ConvertkitService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'convertkit') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_convertkit/src/Extension/ConvertkitService.php b/src/packages/plg_mokojoomcross_convertkit/src/Extension/ConvertkitService.php new file mode 100644 index 0000000..a026fb1 --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/src/Extension/ConvertkitService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Convertkit\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * ConvertKit service plugin for MokoJoomCross. + * + * API: https://api.convertkit.com/v3/broadcasts + */ +class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'convertkit'; } + public function getServiceName(): string { return 'ConvertKit'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['api_secret'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['api_secret'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['api_secret'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'ConvertKit']; + } + + public function getSupportedMediaTypes(): array + { + return []; + } +} diff --git a/src/packages/plg_mokojoomcross_convertkit/src/Extension/index.html b/src/packages/plg_mokojoomcross_convertkit/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_convertkit/src/index.html b/src/packages/plg_mokojoomcross_convertkit/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_convertkit/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/devto.php b/src/packages/plg_mokojoomcross_devto/devto.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/devto.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_devto/devto.xml b/src/packages/plg_mokojoomcross_devto/devto.xml new file mode 100644 index 0000000..6c8a9a1 --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/devto.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Dev.to + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_DEVTO_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Devto + + + devto.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_devto.ini + language/en-GB/plg_mokojoomcross_devto.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_devto/index.html b/src/packages/plg_mokojoomcross_devto/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/language/en-GB/index.html b/src/packages/plg_mokojoomcross_devto/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.ini b/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.ini new file mode 100644 index 0000000..643627f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_DEVTO="MokoJoomCross - Dev.to" +PLG_MOKOJOOMCROSS_DEVTO_DESCRIPTION="Cross-post Joomla articles to Dev.to." diff --git a/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.sys.ini b/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.sys.ini new file mode 100644 index 0000000..643627f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/language/en-GB/plg_mokojoomcross_devto.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_DEVTO="MokoJoomCross - Dev.to" +PLG_MOKOJOOMCROSS_DEVTO_DESCRIPTION="Cross-post Joomla articles to Dev.to." diff --git a/src/packages/plg_mokojoomcross_devto/language/index.html b/src/packages/plg_mokojoomcross_devto/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/services/index.html b/src/packages/plg_mokojoomcross_devto/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/services/provider.php b/src/packages/plg_mokojoomcross_devto/services/provider.php new file mode 100644 index 0000000..ea8cc04 --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Devto\Extension\DevtoService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new DevtoService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'devto') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_devto/src/Extension/DevtoService.php b/src/packages/plg_mokojoomcross_devto/src/Extension/DevtoService.php new file mode 100644 index 0000000..3e607fb --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/src/Extension/DevtoService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Devto\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Dev.to service plugin for MokoJoomCross. + * + * API: https://dev.to/api/articles + */ +class DevtoService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'devto'; } + public function getServiceName(): string { return 'Dev.to'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['api_key'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://dev.to/api/articles', + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Dev.to']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_devto/src/Extension/index.html b/src/packages/plg_mokojoomcross_devto/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_devto/src/index.html b/src/packages/plg_mokojoomcross_devto/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_devto/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_discord/discord.xml b/src/packages/plg_mokojoomcross_discord/discord.xml index 32d324b..41743e9 100644 --- a/src/packages/plg_mokojoomcross_discord/discord.xml +++ b/src/packages/plg_mokojoomcross_discord/discord.xml @@ -1,7 +1,7 @@ MokoJoomCross - Discord - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,24 @@ language/en-GB/plg_mokojoomcross_discord.ini language/en-GB/plg_mokojoomcross_discord.sys.ini + + + +
+ + +
+
+
diff --git a/src/packages/plg_mokojoomcross_discord/language/en-GB/plg_mokojoomcross_discord.ini b/src/packages/plg_mokojoomcross_discord/language/en-GB/plg_mokojoomcross_discord.ini index 10a174f..b8e56eb 100644 --- a/src/packages/plg_mokojoomcross_discord/language/en-GB/plg_mokojoomcross_discord.ini +++ b/src/packages/plg_mokojoomcross_discord/language/en-GB/plg_mokojoomcross_discord.ini @@ -1,2 +1,7 @@ PLG_MOKOJOOMCROSS_DISCORD="MokoJoomCross - Discord" PLG_MOKOJOOMCROSS_DISCORD_DESCRIPTION="Cross-post Joomla articles to Discord." +PLG_MOKOJOOMCROSS_DISCORD_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOJOOMCROSS_DISCORD_DEFAULT_WEBHOOK_URL="Default Webhook URL" +PLG_MOKOJOOMCROSS_DISCORD_DEFAULT_WEBHOOK_URL_DESC="The default MokoWaaS Discord webhook URL used when a service is set to 'default' mode." +PLG_MOKOJOOMCROSS_DISCORD_EMBED_COLOR="Embed Color" +PLG_MOKOJOOMCROSS_DISCORD_EMBED_COLOR_DESC="Default color for Discord embed messages. Defaults to Discord blurple (#5865F2)." diff --git a/src/packages/plg_mokojoomcross_discord/services/provider.php b/src/packages/plg_mokojoomcross_discord/services/provider.php index 03b0862..6edd926 100644 --- a/src/packages/plg_mokojoomcross_discord/services/provider.php +++ b/src/packages/plg_mokojoomcross_discord/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Discord\Extension\DiscordService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_discord/src/Extension/DiscordService.php b/src/packages/plg_mokojoomcross_discord/src/Extension/DiscordService.php index f156998..aab47f8 100644 --- a/src/packages/plg_mokojoomcross_discord/src/Extension/DiscordService.php +++ b/src/packages/plg_mokojoomcross_discord/src/Extension/DiscordService.php @@ -113,7 +113,11 @@ class DiscordService extends CMSPlugin implements SubscriberInterface, MokoJoomC return $credentials['webhook_url'] ?? ''; } - return \Joomla\CMS\Component\ComponentHelper::getParams('com_mokojoomcross') - ->get('discord_default_webhook', ''); + return $this->params->get('default_webhook_url', ''); + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; } } diff --git a/src/packages/plg_mokojoomcross_facebook/facebook.xml b/src/packages/plg_mokojoomcross_facebook/facebook.xml index 4668869..3b099c6 100644 --- a/src/packages/plg_mokojoomcross_facebook/facebook.xml +++ b/src/packages/plg_mokojoomcross_facebook/facebook.xml @@ -1,7 +1,7 @@ MokoJoomCross - Facebook / Meta - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,23 @@ language/en-GB/plg_mokojoomcross_facebook.ini language/en-GB/plg_mokojoomcross_facebook.sys.ini + + + +
+ + +
+
+
diff --git a/src/packages/plg_mokojoomcross_facebook/language/en-GB/plg_mokojoomcross_facebook.ini b/src/packages/plg_mokojoomcross_facebook/language/en-GB/plg_mokojoomcross_facebook.ini index 168e629..6398a2b 100644 --- a/src/packages/plg_mokojoomcross_facebook/language/en-GB/plg_mokojoomcross_facebook.ini +++ b/src/packages/plg_mokojoomcross_facebook/language/en-GB/plg_mokojoomcross_facebook.ini @@ -1,2 +1,7 @@ PLG_MOKOJOOMCROSS_FACEBOOK="MokoJoomCross - Facebook / Meta" PLG_MOKOJOOMCROSS_FACEBOOK_DESCRIPTION="Cross-post Joomla articles to Facebook / Meta." +PLG_MOKOJOOMCROSS_FACEBOOK_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOJOOMCROSS_FACEBOOK_DEFAULT_PAGE_ACCESS_TOKEN="Default Page Access Token" +PLG_MOKOJOOMCROSS_FACEBOOK_DEFAULT_PAGE_ACCESS_TOKEN_DESC="The default MokoWaaS Facebook Page Access Token used when a service is set to 'default' mode." +PLG_MOKOJOOMCROSS_FACEBOOK_DEFAULT_PAGE_ID="Default Page ID" +PLG_MOKOJOOMCROSS_FACEBOOK_DEFAULT_PAGE_ID_DESC="The default Facebook Page ID used when a service is set to 'default' mode." diff --git a/src/packages/plg_mokojoomcross_facebook/services/provider.php b/src/packages/plg_mokojoomcross_facebook/services/provider.php index 3ed00d2..b93bc16 100644 --- a/src/packages/plg_mokojoomcross_facebook/services/provider.php +++ b/src/packages/plg_mokojoomcross_facebook/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Facebook\Extension\FacebookService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_facebook/src/Extension/FacebookService.php b/src/packages/plg_mokojoomcross_facebook/src/Extension/FacebookService.php index fc9dc3c..bb57b33 100644 --- a/src/packages/plg_mokojoomcross_facebook/src/Extension/FacebookService.php +++ b/src/packages/plg_mokojoomcross_facebook/src/Extension/FacebookService.php @@ -139,7 +139,11 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoJoom return $credentials['page_access_token'] ?? ''; } - return \Joomla\CMS\Component\ComponentHelper::getParams('com_mokojoomcross') - ->get('facebook_default_token', ''); + return $this->params->get('default_page_access_token', ''); + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'gif']; } } diff --git a/src/packages/plg_mokojoomcross_ghost/ghost.php b/src/packages/plg_mokojoomcross_ghost/ghost.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/ghost.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_ghost/ghost.xml b/src/packages/plg_mokojoomcross_ghost/ghost.xml new file mode 100644 index 0000000..8a80d51 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/ghost.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Ghost + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_GHOST_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Ghost + + + ghost.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_ghost.ini + language/en-GB/plg_mokojoomcross_ghost.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_ghost/index.html b/src/packages/plg_mokojoomcross_ghost/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ghost/language/en-GB/index.html b/src/packages/plg_mokojoomcross_ghost/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.ini b/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.ini new file mode 100644 index 0000000..2871fb5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GHOST="MokoJoomCross - Ghost" +PLG_MOKOJOOMCROSS_GHOST_DESCRIPTION="Cross-post Joomla articles to Ghost." diff --git a/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.sys.ini b/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.sys.ini new file mode 100644 index 0000000..2871fb5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/language/en-GB/plg_mokojoomcross_ghost.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GHOST="MokoJoomCross - Ghost" +PLG_MOKOJOOMCROSS_GHOST_DESCRIPTION="Cross-post Joomla articles to Ghost." diff --git a/src/packages/plg_mokojoomcross_ghost/language/index.html b/src/packages/plg_mokojoomcross_ghost/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ghost/services/index.html b/src/packages/plg_mokojoomcross_ghost/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ghost/services/provider.php b/src/packages/plg_mokojoomcross_ghost/services/provider.php new file mode 100644 index 0000000..6c2a8df --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Ghost\Extension\GhostService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new GhostService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'ghost') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_ghost/src/Extension/GhostService.php b/src/packages/plg_mokojoomcross_ghost/src/Extension/GhostService.php new file mode 100644 index 0000000..5b3cde0 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/src/Extension/GhostService.php @@ -0,0 +1,166 @@ + + * @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\MokoJoomCross\Ghost\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Ghost service plugin for MokoJoomCross. + * + * Uses Ghost Admin API v5 with JWT authentication. + * The admin_api_key is in format {id}:{secret} — split to build a short-lived JWT. + */ +class GhostService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'ghost'; } + public function getServiceName(): string { return 'Ghost'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $siteUrl = rtrim($credentials['site_url'] ?? '', '/'); + $apiKey = $credentials['admin_api_key'] ?? ''; + $status = $credentials['default_status'] ?? 'draft'; + + if (empty($siteUrl) || empty($apiKey)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing site URL or admin API key.']]; + } + + $jwt = $this->buildGhostJwt($apiKey); + + if (empty($jwt)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Invalid admin API key format. Expected {id}:{secret}.']]; + } + + $apiUrl = $siteUrl . '/ghost/api/admin/posts/'; + $payload = json_encode([ + 'posts' => [[ + 'title' => mb_substr(strip_tags($message), 0, 255), + 'html' => $message, + 'status' => $status, + ]], + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Ghost ' . $jwt, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 201 && !empty($data['posts'][0]['id'])) { + return ['success' => true, 'platform_post_id' => $data['posts'][0]['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $siteUrl = rtrim($credentials['site_url'] ?? '', '/'); + $apiKey = $credentials['admin_api_key'] ?? ''; + + if (empty($siteUrl) || empty($apiKey)) { + return ['valid' => false, 'message' => 'Site URL and admin API key are required.', 'account_name' => '']; + } + + $jwt = $this->buildGhostJwt($apiKey); + + if (empty($jwt)) { + return ['valid' => false, 'message' => 'Invalid API key format. Expected {id}:{secret}.', 'account_name' => '']; + } + + $ch = curl_init($siteUrl . '/ghost/api/admin/site/'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Ghost ' . $jwt], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['site']['title'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['site']['title']]; + } + + return ['valid' => false, 'message' => $data['errors'][0]['message'] ?? 'Failed to connect.', 'account_name' => '']; + } + + /** + * Build a short-lived JWT for Ghost Admin API. + * + * Ghost admin API keys are in format "{id}:{secret}". The JWT uses HS256 + * with the secret (hex-decoded) as the signing key. + */ + private function buildGhostJwt(string $apiKey): string + { + $parts = explode(':', $apiKey, 2); + + if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) { + return ''; + } + + [$keyId, $secret] = $parts; + + $header = $this->base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $keyId])); + $now = time(); + $payload = $this->base64url(json_encode([ + 'iat' => $now, + 'exp' => $now + 300, + 'aud' => '/admin/', + ])); + + $signingKey = hex2bin($secret); + $signature = $this->base64url(hash_hmac('sha256', $header . '.' . $payload, $signingKey, true)); + + return $header . '.' . $payload . '.' . $signature; + } + + private function base64url(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_ghost/src/Extension/index.html b/src/packages/plg_mokojoomcross_ghost/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ghost/src/index.html b/src/packages/plg_mokojoomcross_ghost/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ghost/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.php b/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.xml b/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.xml new file mode 100644 index 0000000..b40ac00 --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/googlebusiness.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Google Business Profile + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_GOOGLEBUSINESS_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\GoogleBusiness + + + googlebusiness.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_googlebusiness.ini + language/en-GB/plg_mokojoomcross_googlebusiness.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_googlebusiness/index.html b/src/packages/plg_mokojoomcross_googlebusiness/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/index.html b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.ini b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.ini new file mode 100644 index 0000000..8415f6e --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GOOGLEBUSINESS="MokoJoomCross - Google Business Profile" +PLG_MOKOJOOMCROSS_GOOGLEBUSINESS_DESCRIPTION="Cross-post Joomla articles to Google Business Profile." diff --git a/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.sys.ini b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.sys.ini new file mode 100644 index 0000000..8415f6e --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/language/en-GB/plg_mokojoomcross_googlebusiness.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GOOGLEBUSINESS="MokoJoomCross - Google Business Profile" +PLG_MOKOJOOMCROSS_GOOGLEBUSINESS_DESCRIPTION="Cross-post Joomla articles to Google Business Profile." diff --git a/src/packages/plg_mokojoomcross_googlebusiness/language/index.html b/src/packages/plg_mokojoomcross_googlebusiness/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/services/index.html b/src/packages/plg_mokojoomcross_googlebusiness/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/services/provider.php b/src/packages/plg_mokojoomcross_googlebusiness/services/provider.php new file mode 100644 index 0000000..b0d35f5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\GoogleBusiness\Extension\GoogleBusinessService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new GoogleBusinessService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'googlebusiness') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/GoogleBusinessService.php b/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/GoogleBusinessService.php new file mode 100644 index 0000000..02d6a36 --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/GoogleBusinessService.php @@ -0,0 +1,120 @@ + + * @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\MokoJoomCross\GoogleBusiness\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Google Business Profile service plugin for MokoJoomCross. + * + * Uses the My Business API v4 to create local posts. + */ +class GoogleBusinessService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'googlebusiness'; } + public function getServiceName(): string { return 'Google Business Profile'; } + public function getMaxLength(): int { return 1500; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + $accountId = $credentials['account_id'] ?? ''; + $locationId = $credentials['location_id'] ?? ''; + + if (empty($token) || empty($accountId) || empty($locationId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token, account ID, or location ID.']]; + } + + $apiUrl = 'https://mybusiness.googleapis.com/v4/' + . urlencode($accountId) . '/' + . urlencode($locationId) . '/localPosts'; + + $payload = json_encode([ + 'languageCode' => 'en', + 'summary' => mb_substr($message, 0, 1500), + 'topicType' => 'STANDARD', + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['name'])) { + return ['success' => true, 'platform_post_id' => $data['name'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['access_token'] ?? ''; + $accountId = $credentials['account_id'] ?? ''; + $locationId = $credentials['location_id'] ?? ''; + + if (empty($token) || empty($accountId) || empty($locationId)) { + return ['valid' => false, 'message' => 'Access token, account ID, and location ID are required.', 'account_name' => '']; + } + + $ch = curl_init('https://mybusiness.googleapis.com/v4/' . urlencode($accountId) . '/' . urlencode($locationId)); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['locationName'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['locationName']]; + } + + return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/index.html b/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlebusiness/src/index.html b/src/packages/plg_mokojoomcross_googlebusiness/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlebusiness/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/googlechat.php b/src/packages/plg_mokojoomcross_googlechat/googlechat.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/googlechat.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_googlechat/googlechat.xml b/src/packages/plg_mokojoomcross_googlechat/googlechat.xml new file mode 100644 index 0000000..745007c --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/googlechat.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Google Chat + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_GOOGLECHAT_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\GoogleChat + + + googlechat.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_googlechat.ini + language/en-GB/plg_mokojoomcross_googlechat.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_googlechat/index.html b/src/packages/plg_mokojoomcross_googlechat/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/language/en-GB/index.html b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.ini b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.ini new file mode 100644 index 0000000..aef532c --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GOOGLECHAT="MokoJoomCross - Google Chat" +PLG_MOKOJOOMCROSS_GOOGLECHAT_DESCRIPTION="Cross-post Joomla articles to Google Chat." diff --git a/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.sys.ini b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.sys.ini new file mode 100644 index 0000000..aef532c --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/language/en-GB/plg_mokojoomcross_googlechat.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_GOOGLECHAT="MokoJoomCross - Google Chat" +PLG_MOKOJOOMCROSS_GOOGLECHAT_DESCRIPTION="Cross-post Joomla articles to Google Chat." diff --git a/src/packages/plg_mokojoomcross_googlechat/language/index.html b/src/packages/plg_mokojoomcross_googlechat/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/services/index.html b/src/packages/plg_mokojoomcross_googlechat/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/services/provider.php b/src/packages/plg_mokojoomcross_googlechat/services/provider.php new file mode 100644 index 0000000..4589c5c --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\GoogleChat\Extension\GoogleChatService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new GoogleChatService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'googlechat') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_googlechat/src/Extension/GoogleChatService.php b/src/packages/plg_mokojoomcross_googlechat/src/Extension/GoogleChatService.php new file mode 100644 index 0000000..83de17f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/src/Extension/GoogleChatService.php @@ -0,0 +1,93 @@ + + * @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\MokoJoomCross\GoogleChat\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Google Chat service plugin for MokoJoomCross. + * + * API: configured webhook URL + */ +class GoogleChatService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'googlechat'; } + public function getServiceName(): string { return 'Google Chat'; } + public function getMaxLength(): int { return 4096; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($url)) { + $url = $this->params->get('default_webhook_url', ''); + } + + if (empty($url)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing webhook URL']]; + } + + $postData = json_encode(['text' => $message]); + + $ch = curl_init($url); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: ['raw' => $response]; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Google Chat']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_googlechat/src/Extension/index.html b/src/packages/plg_mokojoomcross_googlechat/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_googlechat/src/index.html b/src/packages/plg_mokojoomcross_googlechat/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_googlechat/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/hashnode.php b/src/packages/plg_mokojoomcross_hashnode/hashnode.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/hashnode.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_hashnode/hashnode.xml b/src/packages/plg_mokojoomcross_hashnode/hashnode.xml new file mode 100644 index 0000000..060663c --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/hashnode.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Hashnode + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_HASHNODE_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Hashnode + + + hashnode.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_hashnode.ini + language/en-GB/plg_mokojoomcross_hashnode.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_hashnode/index.html b/src/packages/plg_mokojoomcross_hashnode/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/language/en-GB/index.html b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.ini b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.ini new file mode 100644 index 0000000..53652d7 --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_HASHNODE="MokoJoomCross - Hashnode" +PLG_MOKOJOOMCROSS_HASHNODE_DESCRIPTION="Cross-post Joomla articles to Hashnode." diff --git a/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.sys.ini b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.sys.ini new file mode 100644 index 0000000..53652d7 --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/language/en-GB/plg_mokojoomcross_hashnode.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_HASHNODE="MokoJoomCross - Hashnode" +PLG_MOKOJOOMCROSS_HASHNODE_DESCRIPTION="Cross-post Joomla articles to Hashnode." diff --git a/src/packages/plg_mokojoomcross_hashnode/language/index.html b/src/packages/plg_mokojoomcross_hashnode/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/services/index.html b/src/packages/plg_mokojoomcross_hashnode/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/services/provider.php b/src/packages/plg_mokojoomcross_hashnode/services/provider.php new file mode 100644 index 0000000..86e8a4a --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Hashnode\Extension\HashnodeService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new HashnodeService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'hashnode') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_hashnode/src/Extension/HashnodeService.php b/src/packages/plg_mokojoomcross_hashnode/src/Extension/HashnodeService.php new file mode 100644 index 0000000..526a25d --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/src/Extension/HashnodeService.php @@ -0,0 +1,133 @@ + + * @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\MokoJoomCross\Hashnode\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Hashnode service plugin for MokoJoomCross. + * + * Uses the Hashnode GraphQL API at https://gql.hashnode.com. + */ +class HashnodeService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'hashnode'; } + public function getServiceName(): string { return 'Hashnode'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['token'] ?? ''; + $publicationId = $credentials['publication_id'] ?? ''; + + if (empty($token) || empty($publicationId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing API token or publication ID.']]; + } + + $title = mb_substr(strip_tags($message), 0, 150); + + $query = 'mutation PublishPost($input: PublishPostInput!) { publishPost(input: $input) { post { id url } } }'; + $variables = [ + 'input' => [ + 'title' => $title, + 'contentMarkdown' => $message, + 'publicationId' => $publicationId, + 'tags' => [], + ], + ]; + + $payload = json_encode(['query' => $query, 'variables' => $variables]); + + $ch = curl_init('https://gql.hashnode.com'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + $postId = $data['data']['publishPost']['post']['id'] ?? ''; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($postId)) { + return ['success' => true, 'platform_post_id' => $postId, 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['token'] ?? ''; + $publicationId = $credentials['publication_id'] ?? ''; + + if (empty($token) || empty($publicationId)) { + return ['valid' => false, 'message' => 'API token and publication ID are required.', 'account_name' => '']; + } + + $query = '{ me { username name } }'; + $payload = json_encode(['query' => $query]); + + $ch = curl_init('https://gql.hashnode.com'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + $name = $data['data']['me']['name'] ?? $data['data']['me']['username'] ?? ''; + + if (!empty($name)) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $name]; + } + + return ['valid' => false, 'message' => 'Failed to verify token.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_hashnode/src/Extension/index.html b/src/packages/plg_mokojoomcross_hashnode/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_hashnode/src/index.html b/src/packages/plg_mokojoomcross_hashnode/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_hashnode/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_linkedin/language/en-GB/plg_mokojoomcross_linkedin.ini b/src/packages/plg_mokojoomcross_linkedin/language/en-GB/plg_mokojoomcross_linkedin.ini index f7793bb..f321cd3 100644 --- a/src/packages/plg_mokojoomcross_linkedin/language/en-GB/plg_mokojoomcross_linkedin.ini +++ b/src/packages/plg_mokojoomcross_linkedin/language/en-GB/plg_mokojoomcross_linkedin.ini @@ -1,2 +1,9 @@ PLG_MOKOJOOMCROSS_LINKEDIN="MokoJoomCross - LinkedIn" PLG_MOKOJOOMCROSS_LINKEDIN_DESCRIPTION="Cross-post Joomla articles to LinkedIn." +PLG_MOKOJOOMCROSS_LINKEDIN_FIELDSET_DEFAULTS="LinkedIn Defaults" +PLG_MOKOJOOMCROSS_LINKEDIN_CLIENT_ID="Client ID" +PLG_MOKOJOOMCROSS_LINKEDIN_CLIENT_ID_DESC="LinkedIn App Client ID." +PLG_MOKOJOOMCROSS_LINKEDIN_CLIENT_SECRET="Client Secret" +PLG_MOKOJOOMCROSS_LINKEDIN_CLIENT_SECRET_DESC="LinkedIn App Client Secret." +PLG_MOKOJOOMCROSS_LINKEDIN_REDIRECT_URI="Redirect URI" +PLG_MOKOJOOMCROSS_LINKEDIN_REDIRECT_URI_DESC="OAuth callback URL for LinkedIn authentication." diff --git a/src/packages/plg_mokojoomcross_linkedin/linkedin.xml b/src/packages/plg_mokojoomcross_linkedin/linkedin.xml index 0aa24ab..3ebc3bd 100644 --- a/src/packages/plg_mokojoomcross_linkedin/linkedin.xml +++ b/src/packages/plg_mokojoomcross_linkedin/linkedin.xml @@ -1,7 +1,7 @@ MokoJoomCross - LinkedIn - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,29 @@ language/en-GB/plg_mokojoomcross_linkedin.ini language/en-GB/plg_mokojoomcross_linkedin.sys.ini + + + +
+ + + +
+
+
diff --git a/src/packages/plg_mokojoomcross_linkedin/services/provider.php b/src/packages/plg_mokojoomcross_linkedin/services/provider.php index 53e3007..7a750ee 100644 --- a/src/packages/plg_mokojoomcross_linkedin/services/provider.php +++ b/src/packages/plg_mokojoomcross_linkedin/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Linkedin\Extension\LinkedinService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_linkedin/src/Extension/LinkedinService.php b/src/packages/plg_mokojoomcross_linkedin/src/Extension/LinkedinService.php index 6beb24b..6070f22 100644 --- a/src/packages/plg_mokojoomcross_linkedin/src/Extension/LinkedinService.php +++ b/src/packages/plg_mokojoomcross_linkedin/src/Extension/LinkedinService.php @@ -131,4 +131,9 @@ class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoJoom { return true; } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } } diff --git a/src/packages/plg_mokojoomcross_mailchimp/language/en-GB/plg_mokojoomcross_mailchimp.ini b/src/packages/plg_mokojoomcross_mailchimp/language/en-GB/plg_mokojoomcross_mailchimp.ini index 6b08b10..ccf1c59 100644 --- a/src/packages/plg_mokojoomcross_mailchimp/language/en-GB/plg_mokojoomcross_mailchimp.ini +++ b/src/packages/plg_mokojoomcross_mailchimp/language/en-GB/plg_mokojoomcross_mailchimp.ini @@ -1,2 +1,9 @@ PLG_MOKOJOOMCROSS_MAILCHIMP="MokoJoomCross - Mailchimp" PLG_MOKOJOOMCROSS_MAILCHIMP_DESCRIPTION="Cross-post Joomla articles to Mailchimp." +PLG_MOKOJOOMCROSS_MAILCHIMP_FIELDSET_DEFAULTS="Mailchimp Defaults" +PLG_MOKOJOOMCROSS_MAILCHIMP_DEFAULT_FROM_NAME="Default From Name" +PLG_MOKOJOOMCROSS_MAILCHIMP_DEFAULT_FROM_NAME_DESC="Default sender name for Mailchimp campaigns." +PLG_MOKOJOOMCROSS_MAILCHIMP_DEFAULT_FROM_EMAIL="Default From Email" +PLG_MOKOJOOMCROSS_MAILCHIMP_DEFAULT_FROM_EMAIL_DESC="Default sender email address for Mailchimp campaigns." +PLG_MOKOJOOMCROSS_MAILCHIMP_AUTO_SEND="Auto Send" +PLG_MOKOJOOMCROSS_MAILCHIMP_AUTO_SEND_DESC="Automatically send the campaign on creation instead of saving as draft." diff --git a/src/packages/plg_mokojoomcross_mailchimp/mailchimp.xml b/src/packages/plg_mokojoomcross_mailchimp/mailchimp.xml index e047864..7f90fe2 100644 --- a/src/packages/plg_mokojoomcross_mailchimp/mailchimp.xml +++ b/src/packages/plg_mokojoomcross_mailchimp/mailchimp.xml @@ -1,7 +1,7 @@ MokoJoomCross - Mailchimp - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,34 @@ language/en-GB/plg_mokojoomcross_mailchimp.ini language/en-GB/plg_mokojoomcross_mailchimp.sys.ini + + + +
+ + + + + + +
+
+
diff --git a/src/packages/plg_mokojoomcross_mailchimp/services/provider.php b/src/packages/plg_mokojoomcross_mailchimp/services/provider.php index bca54a1..2d2b185 100644 --- a/src/packages/plg_mokojoomcross_mailchimp/services/provider.php +++ b/src/packages/plg_mokojoomcross_mailchimp/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Mailchimp\Extension\MailchimpService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_mailchimp/src/Extension/MailchimpService.php b/src/packages/plg_mokojoomcross_mailchimp/src/Extension/MailchimpService.php index cd6f156..090b5af 100644 --- a/src/packages/plg_mokojoomcross_mailchimp/src/Extension/MailchimpService.php +++ b/src/packages/plg_mokojoomcross_mailchimp/src/Extension/MailchimpService.php @@ -142,4 +142,9 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoJoo return end($parts) ?: 'us1'; } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } } diff --git a/src/packages/plg_mokojoomcross_mastodon/language/en-GB/plg_mokojoomcross_mastodon.ini b/src/packages/plg_mokojoomcross_mastodon/language/en-GB/plg_mokojoomcross_mastodon.ini index b663b97..08ac03b 100644 --- a/src/packages/plg_mokojoomcross_mastodon/language/en-GB/plg_mokojoomcross_mastodon.ini +++ b/src/packages/plg_mokojoomcross_mastodon/language/en-GB/plg_mokojoomcross_mastodon.ini @@ -1,2 +1,13 @@ PLG_MOKOJOOMCROSS_MASTODON="MokoJoomCross - Mastodon" PLG_MOKOJOOMCROSS_MASTODON_DESCRIPTION="Cross-post Joomla articles to Mastodon." +PLG_MOKOJOOMCROSS_MASTODON_FIELDSET_DEFAULTS="Mastodon Defaults" +PLG_MOKOJOOMCROSS_MASTODON_DEFAULT_INSTANCE_URL="Default Instance URL" +PLG_MOKOJOOMCROSS_MASTODON_DEFAULT_INSTANCE_URL_DESC="Default Mastodon instance URL (e.g. https://mastodon.social)." +PLG_MOKOJOOMCROSS_MASTODON_DEFAULT_VISIBILITY="Default Visibility" +PLG_MOKOJOOMCROSS_MASTODON_DEFAULT_VISIBILITY_DESC="Default post visibility for Mastodon toots." +PLG_MOKOJOOMCROSS_MASTODON_VISIBILITY_PUBLIC="Public" +PLG_MOKOJOOMCROSS_MASTODON_VISIBILITY_UNLISTED="Unlisted" +PLG_MOKOJOOMCROSS_MASTODON_VISIBILITY_PRIVATE="Private" +PLG_MOKOJOOMCROSS_MASTODON_VISIBILITY_DIRECT="Direct" +PLG_MOKOJOOMCROSS_MASTODON_APPEND_HASHTAGS="Append Hashtags" +PLG_MOKOJOOMCROSS_MASTODON_APPEND_HASHTAGS_DESC="Default hashtags to append to posts (e.g. #Joomla #MokoWaaS)." diff --git a/src/packages/plg_mokojoomcross_mastodon/mastodon.xml b/src/packages/plg_mokojoomcross_mastodon/mastodon.xml index 8daec91..2b8b112 100644 --- a/src/packages/plg_mokojoomcross_mastodon/mastodon.xml +++ b/src/packages/plg_mokojoomcross_mastodon/mastodon.xml @@ -1,7 +1,7 @@ MokoJoomCross - Mastodon - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,35 @@ language/en-GB/plg_mokojoomcross_mastodon.ini language/en-GB/plg_mokojoomcross_mastodon.sys.ini + + + +
+ + + + + + + + +
+
+
diff --git a/src/packages/plg_mokojoomcross_mastodon/services/provider.php b/src/packages/plg_mokojoomcross_mastodon/services/provider.php index 0033174..4f67e18 100644 --- a/src/packages/plg_mokojoomcross_mastodon/services/provider.php +++ b/src/packages/plg_mokojoomcross_mastodon/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Mastodon\Extension\MastodonService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_mastodon/src/Extension/MastodonService.php b/src/packages/plg_mokojoomcross_mastodon/src/Extension/MastodonService.php index dd008f4..9901140 100644 --- a/src/packages/plg_mokojoomcross_mastodon/src/Extension/MastodonService.php +++ b/src/packages/plg_mokojoomcross_mastodon/src/Extension/MastodonService.php @@ -99,4 +99,9 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoJoom return ['valid' => false, 'message' => 'Failed', 'account_name' => '']; } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'gif']; + } } diff --git a/src/packages/plg_mokojoomcross_matrix/index.html b/src/packages/plg_mokojoomcross_matrix/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_matrix/language/en-GB/index.html b/src/packages/plg_mokojoomcross_matrix/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.ini b/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.ini new file mode 100644 index 0000000..f699694 --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_MATRIX="MokoJoomCross - Matrix / Element" +PLG_MOKOJOOMCROSS_MATRIX_DESCRIPTION="Cross-post Joomla articles to Matrix / Element." diff --git a/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.sys.ini b/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.sys.ini new file mode 100644 index 0000000..f699694 --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/language/en-GB/plg_mokojoomcross_matrix.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_MATRIX="MokoJoomCross - Matrix / Element" +PLG_MOKOJOOMCROSS_MATRIX_DESCRIPTION="Cross-post Joomla articles to Matrix / Element." diff --git a/src/packages/plg_mokojoomcross_matrix/language/index.html b/src/packages/plg_mokojoomcross_matrix/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_matrix/matrix.php b/src/packages/plg_mokojoomcross_matrix/matrix.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/matrix.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_matrix/matrix.xml b/src/packages/plg_mokojoomcross_matrix/matrix.xml new file mode 100644 index 0000000..eb5cc1a --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/matrix.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Matrix / Element + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_MATRIX_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Matrix + + + matrix.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_matrix.ini + language/en-GB/plg_mokojoomcross_matrix.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_matrix/services/index.html b/src/packages/plg_mokojoomcross_matrix/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_matrix/services/provider.php b/src/packages/plg_mokojoomcross_matrix/services/provider.php new file mode 100644 index 0000000..def9147 --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Matrix\Extension\MatrixService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MatrixService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'matrix') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_matrix/src/Extension/MatrixService.php b/src/packages/plg_mokojoomcross_matrix/src/Extension/MatrixService.php new file mode 100644 index 0000000..2327ae4 --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/src/Extension/MatrixService.php @@ -0,0 +1,123 @@ + + * @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\MokoJoomCross\Matrix\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Matrix / Element service plugin for MokoJoomCross. + * + * Uses the Matrix Client-Server API v3 to send messages to rooms. + * Message send uses PUT with a transaction ID to prevent duplicates. + */ +class MatrixService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'matrix'; } + public function getServiceName(): string { return 'Matrix / Element'; } + public function getMaxLength(): int { return 65536; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $homeserver = rtrim($credentials['homeserver'] ?? '', '/'); + $token = $credentials['access_token'] ?? ''; + $roomId = $credentials['room_id'] ?? ''; + + if (empty($homeserver) || empty($token) || empty($roomId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing homeserver, access token, or room ID.']]; + } + + // Matrix uses PUT with a transaction ID to ensure idempotency + $txnId = bin2hex(random_bytes(16)); + $apiUrl = $homeserver . '/_matrix/client/v3/rooms/' + . rawurlencode($roomId) . '/send/m.room.message/' . $txnId; + + $payload = json_encode([ + 'msgtype' => 'm.text', + 'body' => strip_tags($message), + 'format' => 'org.matrix.custom.html', + 'formatted_body' => $message, + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['event_id'])) { + return ['success' => true, 'platform_post_id' => $data['event_id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $homeserver = rtrim($credentials['homeserver'] ?? '', '/'); + $token = $credentials['access_token'] ?? ''; + $roomId = $credentials['room_id'] ?? ''; + + if (empty($homeserver) || empty($token) || empty($roomId)) { + return ['valid' => false, 'message' => 'Homeserver, access token, and room ID are required.', 'account_name' => '']; + } + + $ch = curl_init($homeserver . '/_matrix/client/v3/account/whoami'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['user_id'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['user_id']]; + } + + return ['valid' => false, 'message' => $data['error'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/src/packages/plg_mokojoomcross_matrix/src/Extension/index.html b/src/packages/plg_mokojoomcross_matrix/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_matrix/src/index.html b/src/packages/plg_mokojoomcross_matrix/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_matrix/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/index.html b/src/packages/plg_mokojoomcross_medium/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/language/en-GB/index.html b/src/packages/plg_mokojoomcross_medium/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.ini b/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.ini new file mode 100644 index 0000000..d409045 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_MEDIUM="MokoJoomCross - Medium" +PLG_MOKOJOOMCROSS_MEDIUM_DESCRIPTION="Cross-post Joomla articles to Medium." diff --git a/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.sys.ini b/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.sys.ini new file mode 100644 index 0000000..d409045 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/language/en-GB/plg_mokojoomcross_medium.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_MEDIUM="MokoJoomCross - Medium" +PLG_MOKOJOOMCROSS_MEDIUM_DESCRIPTION="Cross-post Joomla articles to Medium." diff --git a/src/packages/plg_mokojoomcross_medium/language/index.html b/src/packages/plg_mokojoomcross_medium/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/medium.php b/src/packages/plg_mokojoomcross_medium/medium.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/medium.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_medium/medium.xml b/src/packages/plg_mokojoomcross_medium/medium.xml new file mode 100644 index 0000000..d129fa2 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/medium.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Medium + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_MEDIUM_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Medium + + + medium.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_medium.ini + language/en-GB/plg_mokojoomcross_medium.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_medium/services/index.html b/src/packages/plg_mokojoomcross_medium/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/services/provider.php b/src/packages/plg_mokojoomcross_medium/services/provider.php new file mode 100644 index 0000000..c214bc8 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Medium\Extension\MediumService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MediumService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'medium') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_medium/src/Extension/MediumService.php b/src/packages/plg_mokojoomcross_medium/src/Extension/MediumService.php new file mode 100644 index 0000000..f9a85e1 --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/src/Extension/MediumService.php @@ -0,0 +1,141 @@ + + * @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\MokoJoomCross\Medium\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Medium service plugin for MokoJoomCross. + * + * Uses the Medium Publishing API. Requires fetching the user ID first + * via /v1/me, then posting to /v1/users/{id}/posts. + */ +class MediumService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'medium'; } + public function getServiceName(): string { return 'Medium'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token.']]; + } + + // Step 1: Get the authenticated user's ID + $userId = $this->getUserId($token); + + if (empty($userId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Failed to fetch Medium user ID.']]; + } + + // Step 2: Create the post + $apiUrl = 'https://api.medium.com/v1/users/' . $userId . '/posts'; + $title = mb_substr(strip_tags($message), 0, 150); + $payload = json_encode([ + 'title' => $title, + 'contentFormat' => 'html', + 'content' => $message, + 'publishStatus' => 'draft', + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 201 && !empty($data['data']['id'])) { + return ['success' => true, 'platform_post_id' => $data['data']['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['valid' => false, 'message' => 'Access token is required.', 'account_name' => '']; + } + + $ch = curl_init('https://api.medium.com/v1/me'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['data']['username'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $data['data']['username']]; + } + + return ['valid' => false, 'message' => 'Failed to verify token.', 'account_name' => '']; + } + + private function getUserId(string $token): string + { + $ch = curl_init('https://api.medium.com/v1/me'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + return $data['data']['id'] ?? ''; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_medium/src/Extension/index.html b/src/packages/plg_mokojoomcross_medium/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_medium/src/index.html b/src/packages/plg_mokojoomcross_medium/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_medium/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.ini b/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.ini new file mode 100644 index 0000000..b7e1b91 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.ini @@ -0,0 +1,14 @@ +; MokoJoomCross - MokoJoomCalendar Events Service +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR="MokoJoomCross - MokoJoomCalendar Events" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION="Cross-posts MokoJoomCalendar events to connected platforms. Enriches messages with event date, time, location, and calendar details." + +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_FIELDSET_DEFAULTS="Event Cross-Post Settings" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION="Include Location" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION_DESC="Append the event location to the cross-post message." +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE="Include Date/Time" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE_DESC="Append the event date and time to the cross-post message." +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT="Date Format" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT_DESC="PHP date format string for event dates. Default: l, F j, Y at g:ia" diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.sys.ini b/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.sys.ini new file mode 100644 index 0000000..1c35208 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/language/en-GB/plg_mokojoomcross_mokojoomcalendar.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomCross - MokoJoomCalendar Events Service +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR="Plugin - MokoJoomCross MokoJoomCalendar Events" +PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION="Cross-posts MokoJoomCalendar events to connected platforms." diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.php b/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.php new file mode 100644 index 0000000..2d3c6d4 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.php @@ -0,0 +1,14 @@ + + * @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 + * + * Legacy entry point — required by Joomla's plugin loader. + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.xml b/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.xml new file mode 100644 index 0000000..c1be419 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/mokojoomcalendar.xml @@ -0,0 +1,62 @@ + + + MokoJoomCross - MokoJoomCalendar Events + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\MokoJoomCalendar + + + mokojoomcalendar.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_mokojoomcalendar.ini + language/en-GB/plg_mokojoomcross_mokojoomcalendar.sys.ini + + + + +
+ + + + + + + + + +
+
+
+
diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/services/provider.php b/src/packages/plg_mokojoomcross_mokojoomcalendar/services/provider.php new file mode 100644 index 0000000..d214d6f --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\MokoJoomCalendar\Extension\CalendarService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new CalendarService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'mokojoomcalendar') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_mokojoomcalendar/src/Extension/CalendarService.php b/src/packages/plg_mokojoomcross_mokojoomcalendar/src/Extension/CalendarService.php new file mode 100644 index 0000000..85cbd93 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomcalendar/src/Extension/CalendarService.php @@ -0,0 +1,187 @@ + + * @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\MokoJoomCross\MokoJoomCalendar\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * MokoJoomCalendar service plugin for MokoJoomCross. + * + * Cross-posts calendar events when they are published. + * Enriches the message with event date, time, location, and calendar name. + * + * Credentials format: + * { + * "site_url": "https://example.com" // Optional, defaults to current site + * } + */ +class CalendarService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string + { + return 'mokojoomcalendar'; + } + + public function getServiceName(): string + { + return 'MokoJoomCalendar Events'; + } + + public function getMaxLength(): int + { + return 0; + } + + public function supportsMedia(): bool + { + return true; + } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $db = Factory::getDbo(); + + $articleId = (int) ($params['_article_id'] ?? 0); + + if (!$articleId) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'No article ID']]; + } + + // Load the event data from mokojoomcalendar + $query = $db->getQuery(true) + ->select('e.*, c.title AS calendar_title, c.color AS calendar_color, l.title AS location_title, l.address, l.city, l.state') + ->from($db->quoteName('#__mokojoomcalendar_events', 'e')) + ->leftJoin($db->quoteName('#__mokojoomcalendar_calendars', 'c') . ' ON c.id = e.calendar_id') + ->leftJoin($db->quoteName('#__mokojoomcalendar_locations', 'l') . ' ON l.id = e.location_id') + ->where($db->quoteName('e.id') . ' = ' . $articleId); + + $db->setQuery($query); + $event = $db->loadObject(); + + if (!$event) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Event not found']]; + } + + // Enrich the message with event metadata + $enriched = $this->enrichMessage($message, $event, $params); + + // Build media array from event image + if (empty($media) && !empty($event->image)) { + $siteUrl = rtrim($credentials['site_url'] ?? Uri::root(), '/'); + $media = [$siteUrl . '/' . ltrim($event->image, '/')]; + } + + // Store enriched post internally (this plugin creates queue entries for other services) + return [ + 'success' => true, + 'platform_post_id' => 'event-' . $event->id, + 'response' => [ + 'event_id' => (int) $event->id, + 'event_title' => $event->title, + 'calendar' => $event->calendar_title, + 'start_date' => $event->start_date, + 'end_date' => $event->end_date, + 'location' => $event->location_title, + 'enriched_message' => $enriched, + 'media' => $media, + ], + ]; + } + + public function validateCredentials(array $credentials): array + { + // Check if com_mokojoomcalendar is installed + if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomcalendar')) { + return [ + 'valid' => false, + 'message' => 'MokoJoomCalendar component is not installed.', + 'account_name' => '', + ]; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcalendar_events')); + $db->setQuery($query); + $count = (int) $db->loadResult(); + + return [ + 'valid' => true, + 'message' => "Connected. {$count} event(s) in database.", + 'account_name' => 'MokoJoomCalendar', + ]; + } + + /** + * Enrich the cross-post message with event-specific data. + */ + private function enrichMessage(string $message, object $event, array $params): string + { + $parts = [$message]; + + // Add date/time + if ((int) ($params['include_date'] ?? $this->params->get('include_date', 1))) { + $format = $params['date_format'] ?? $this->params->get('date_format', 'l, F j, Y \\a\\t g:ia'); + $startDate = Factory::getDate($event->start_date); + $dateStr = $startDate->format($format); + + if (!empty($event->end_date) && $event->end_date !== $event->start_date) { + $endDate = Factory::getDate($event->end_date); + $dateStr .= ' — ' . $endDate->format($format); + } + + if (!empty($event->all_day)) { + $dateStr = $startDate->format('l, F j, Y') . ' (All Day)'; + } + + $parts[] = $dateStr; + } + + // Add location + if ((int) ($params['include_location'] ?? $this->params->get('include_location', 1))) { + $locationParts = array_filter([ + $event->location_title ?? '', + $event->address ?? '', + $event->city ?? '', + $event->state ?? '', + ]); + + if (!empty($locationParts)) { + $parts[] = implode(', ', $locationParts); + } + } + + return implode("\n\n", array_filter($parts)); + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.ini b/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.ini new file mode 100644 index 0000000..89929e8 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.ini @@ -0,0 +1,16 @@ +; MokoJoomCross - MokoJoomGallery Service +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY="MokoJoomCross - MokoJoomGallery" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION="Cross-posts MokoJoomGallery content to connected platforms. Supports gallery announcements with preview images and individual image posts." + +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_FIELDSET_DEFAULTS="Gallery Cross-Post Settings" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE="Post Mode" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE_DESC="Gallery mode posts when a gallery is published (with preview images). Image mode posts each individual image." +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_GALLERY="Gallery (with preview images)" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_IMAGE="Individual Images" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES="Max Preview Images" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES_DESC="Maximum number of preview images to attach when cross-posting a gallery." +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION="Include Description" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION_DESC="Append the gallery or image description to the cross-post message." diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.sys.ini b/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.sys.ini new file mode 100644 index 0000000..ce03ec5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/language/en-GB/plg_mokojoomcross_mokojoomgallery.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomCross - MokoJoomGallery Service +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY="Plugin - MokoJoomCross MokoJoomGallery" +PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to connected platforms." diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.php b/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.php new file mode 100644 index 0000000..8b82494 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.php @@ -0,0 +1,14 @@ + + * @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 + * + * Legacy entry point — required by Joomla's plugin loader. + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.xml b/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.xml new file mode 100644 index 0000000..0eef0d5 --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/mokojoomgallery.xml @@ -0,0 +1,63 @@ + + + MokoJoomCross - MokoJoomGallery + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\MokoJoomGallery + + + mokojoomgallery.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_mokojoomgallery.ini + language/en-GB/plg_mokojoomcross_mokojoomgallery.sys.ini + + + + +
+ + + + + + + + + +
+
+
+
diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/services/provider.php b/src/packages/plg_mokojoomcross_mokojoomgallery/services/provider.php new file mode 100644 index 0000000..33cecda --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\MokoJoomGallery\Extension\GalleryService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new GalleryService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'mokojoomgallery') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_mokojoomgallery/src/Extension/GalleryService.php b/src/packages/plg_mokojoomcross_mokojoomgallery/src/Extension/GalleryService.php new file mode 100644 index 0000000..a5a8fdd --- /dev/null +++ b/src/packages/plg_mokojoomcross_mokojoomgallery/src/Extension/GalleryService.php @@ -0,0 +1,249 @@ + + * @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\MokoJoomCross\MokoJoomGallery\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * MokoJoomGallery service plugin for MokoJoomCross. + * + * Cross-posts gallery content when new images or galleries are published. + * Two modes: + * - "gallery" mode: posts when a gallery is created/published (includes preview images) + * - "image" mode: posts each individual image when published + * + * Credentials format: + * { + * "site_url": "https://example.com" // Optional, defaults to current site + * } + */ +class GalleryService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string + { + return 'mokojoomgallery'; + } + + public function getServiceName(): string + { + return 'MokoJoomGallery'; + } + + public function getMaxLength(): int + { + return 0; + } + + public function supportsMedia(): bool + { + return true; + } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $db = Factory::getDbo(); + $siteUrl = rtrim($credentials['site_url'] ?? Uri::root(), '/'); + $mode = $params['post_mode'] ?? $this->params->get('post_mode', 'gallery'); + + $articleId = (int) ($params['_article_id'] ?? 0); + + if (!$articleId) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'No content ID']]; + } + + if ($mode === 'image') { + return $this->publishImage($articleId, $message, $media, $siteUrl, $params); + } + + return $this->publishGallery($articleId, $message, $media, $siteUrl, $params); + } + + public function validateCredentials(array $credentials): array + { + if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) { + return [ + 'valid' => false, + 'message' => 'MokoJoomGallery component is not installed.', + 'account_name' => '', + ]; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomgallery_galleries')); + $db->setQuery($query); + $galleries = (int) $db->loadResult(); + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomgallery_images')); + $db->setQuery($query); + $images = (int) $db->loadResult(); + + return [ + 'valid' => true, + 'message' => "Connected. {$galleries} gallery(ies), {$images} image(s).", + 'account_name' => 'MokoJoomGallery', + ]; + } + + /** + * Cross-post a gallery with preview images. + */ + private function publishGallery(int $galleryId, string $message, array $media, string $siteUrl, array $params): array + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomgallery_galleries')) + ->where($db->quoteName('id') . ' = ' . $galleryId); + $db->setQuery($query); + $gallery = $db->loadObject(); + + if (!$gallery) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Gallery not found']]; + } + + // Get preview images + $maxImages = (int) ($params['max_images'] ?? $this->params->get('max_images', 4)); + + $query = $db->getQuery(true) + ->select($db->quoteName(['thumbnail', 'original', 'title'])) + ->from($db->quoteName('#__mokojoomgallery_images')) + ->where($db->quoteName('gallery_id') . ' = ' . $galleryId) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC') + ->setLimit($maxImages); + $db->setQuery($query); + $images = $db->loadObjectList(); + + // Build media URLs from gallery images + if (empty($media) && !empty($images)) { + $media = []; + + foreach ($images as $img) { + $path = $img->thumbnail ?: $img->original; + + if ($path) { + $media[] = $siteUrl . '/' . ltrim($path, '/'); + } + } + } + + // Enrich message with gallery info + $enriched = $message; + + if ((int) ($params['include_description'] ?? $this->params->get('include_description', 1))) { + $desc = strip_tags($gallery->description ?? ''); + + if ($desc !== '') { + $enriched .= "\n\n" . mb_substr($desc, 0, 200); + } + } + + $imageCount = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomgallery_images')) + ->where($db->quoteName('gallery_id') . ' = ' . $galleryId) + ->where($db->quoteName('published') . ' = 1') + )->loadResult(); + + return [ + 'success' => true, + 'platform_post_id' => 'gallery-' . $galleryId, + 'response' => [ + 'gallery_id' => $galleryId, + 'gallery_title' => $gallery->title, + 'image_count' => (int) $imageCount, + 'preview_count' => count($images), + 'enriched_message' => $enriched, + 'media' => $media, + ], + ]; + } + + /** + * Cross-post a single image. + */ + private function publishImage(int $imageId, string $message, array $media, string $siteUrl, array $params): array + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('i.*, g.title AS gallery_title') + ->from($db->quoteName('#__mokojoomgallery_images', 'i')) + ->leftJoin($db->quoteName('#__mokojoomgallery_galleries', 'g') . ' ON g.id = i.gallery_id') + ->where($db->quoteName('i.id') . ' = ' . $imageId); + $db->setQuery($query); + $image = $db->loadObject(); + + if (!$image) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Image not found']]; + } + + // Use image as media + if (empty($media)) { + $path = $image->original ?: $image->thumbnail; + + if ($path) { + $media = [$siteUrl . '/' . ltrim($path, '/')]; + } + } + + // Enrich with image description + $enriched = $message; + + if ((int) ($params['include_description'] ?? $this->params->get('include_description', 1))) { + $desc = strip_tags($image->description ?? ''); + + if ($desc !== '') { + $enriched .= "\n\n" . mb_substr($desc, 0, 200); + } + } + + return [ + 'success' => true, + 'platform_post_id' => 'image-' . $imageId, + 'response' => [ + 'image_id' => $imageId, + 'image_title' => $image->title, + 'gallery_title' => $image->gallery_title, + 'enriched_message' => $enriched, + 'media' => $media, + ], + ]; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/src/packages/plg_mokojoomcross_nostr/index.html b/src/packages/plg_mokojoomcross_nostr/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_nostr/language/en-GB/index.html b/src/packages/plg_mokojoomcross_nostr/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.ini b/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.ini new file mode 100644 index 0000000..e37f27f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_NOSTR="MokoJoomCross - Nostr" +PLG_MOKOJOOMCROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr." diff --git a/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.sys.ini b/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.sys.ini new file mode 100644 index 0000000..e37f27f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/language/en-GB/plg_mokojoomcross_nostr.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_NOSTR="MokoJoomCross - Nostr" +PLG_MOKOJOOMCROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr." diff --git a/src/packages/plg_mokojoomcross_nostr/language/index.html b/src/packages/plg_mokojoomcross_nostr/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_nostr/nostr.php b/src/packages/plg_mokojoomcross_nostr/nostr.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/nostr.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_nostr/nostr.xml b/src/packages/plg_mokojoomcross_nostr/nostr.xml new file mode 100644 index 0000000..e490c7d --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/nostr.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Nostr + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_NOSTR_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Nostr + + + nostr.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_nostr.ini + language/en-GB/plg_mokojoomcross_nostr.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_nostr/services/index.html b/src/packages/plg_mokojoomcross_nostr/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_nostr/services/provider.php b/src/packages/plg_mokojoomcross_nostr/services/provider.php new file mode 100644 index 0000000..dfab0c2 --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Nostr\Extension\NostrService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new NostrService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'nostr') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_nostr/src/Extension/NostrService.php b/src/packages/plg_mokojoomcross_nostr/src/Extension/NostrService.php new file mode 100644 index 0000000..03796a6 --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/src/Extension/NostrService.php @@ -0,0 +1,97 @@ + + * @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\MokoJoomCross\Nostr\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Nostr service plugin for MokoJoomCross. + * + * Nostr uses NIP-01 WebSocket relays for event publishing. + * This is a stub — full WebSocket implementation is deferred. + * Events are signed with the private key and sent to configured relays. + */ +class NostrService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'nostr'; } + public function getServiceName(): string { return 'Nostr'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return false; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $privateKey = $credentials['private_key'] ?? ''; + $relays = $credentials['relays'] ?? ''; + + if (empty($privateKey) || empty($relays)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing private key or relay URLs.']]; + } + + // Nostr requires WebSocket connections to relays (wss://). + // Full NIP-01 event signing and relay publishing is not yet implemented. + return [ + 'success' => false, + 'platform_post_id' => '', + 'response' => ['error' => 'Nostr WebSocket relay publishing is not yet implemented. This service will be available in a future release.'], + ]; + } + + public function validateCredentials(array $credentials): array + { + $privateKey = $credentials['private_key'] ?? ''; + $relays = $credentials['relays'] ?? ''; + + if (empty($privateKey)) { + return ['valid' => false, 'message' => 'Private key is required.', 'account_name' => '']; + } + + if (empty($relays)) { + return ['valid' => false, 'message' => 'At least one relay URL is required.', 'account_name' => '']; + } + + // Validate that relay URLs look like WebSocket URLs + $relayList = array_filter(array_map('trim', explode(',', $relays))); + $valid = true; + + foreach ($relayList as $relay) { + if (!str_starts_with($relay, 'wss://') && !str_starts_with($relay, 'ws://')) { + $valid = false; + break; + } + } + + if (!$valid) { + return ['valid' => false, 'message' => 'Relay URLs must start with wss:// or ws://.', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured (' . count($relayList) . ' relay(s))', 'account_name' => 'Nostr']; + } + + public function getSupportedMediaTypes(): array + { + return []; + } +} diff --git a/src/packages/plg_mokojoomcross_nostr/src/Extension/index.html b/src/packages/plg_mokojoomcross_nostr/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_nostr/src/index.html b/src/packages/plg_mokojoomcross_nostr/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_nostr/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/index.html b/src/packages/plg_mokojoomcross_ntfy/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/language/en-GB/index.html b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.ini b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.ini new file mode 100644 index 0000000..d4e1d26 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_NTFY="MokoJoomCross - Ntfy Push Notifications" +PLG_MOKOJOOMCROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy Push Notifications." diff --git a/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.sys.ini b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.sys.ini new file mode 100644 index 0000000..d4e1d26 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/language/en-GB/plg_mokojoomcross_ntfy.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_NTFY="MokoJoomCross - Ntfy Push Notifications" +PLG_MOKOJOOMCROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy Push Notifications." diff --git a/src/packages/plg_mokojoomcross_ntfy/language/index.html b/src/packages/plg_mokojoomcross_ntfy/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/ntfy.php b/src/packages/plg_mokojoomcross_ntfy/ntfy.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/ntfy.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_ntfy/ntfy.xml b/src/packages/plg_mokojoomcross_ntfy/ntfy.xml new file mode 100644 index 0000000..17606a1 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/ntfy.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Ntfy Push Notifications + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_NTFY_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Ntfy + + + ntfy.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_ntfy.ini + language/en-GB/plg_mokojoomcross_ntfy.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_ntfy/services/index.html b/src/packages/plg_mokojoomcross_ntfy/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/services/provider.php b/src/packages/plg_mokojoomcross_ntfy/services/provider.php new file mode 100644 index 0000000..5f855e9 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Ntfy\Extension\NtfyService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new NtfyService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'ntfy') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_ntfy/src/Extension/NtfyService.php b/src/packages/plg_mokojoomcross_ntfy/src/Extension/NtfyService.php new file mode 100644 index 0000000..7c46413 --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/src/Extension/NtfyService.php @@ -0,0 +1,101 @@ + + * @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\MokoJoomCross\Ntfy\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Ntfy Push Notifications service plugin for MokoJoomCross. + * + * API: {server_url}/{topic} + */ +class NtfyService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'ntfy'; } + public function getServiceName(): string { return 'Ntfy Push Notifications'; } + public function getMaxLength(): int { return 4096; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['topic'] ?? $credentials['webhook_url'] ?? ''; + + $serverUrl = rtrim($credentials['server_url'] ?? 'https://ntfy.sh', '/'); + $topic = $credentials['topic'] ?? ''; + $token = $credentials['token'] ?? ''; + + if (empty($topic)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing topic']]; + } + + $headers = ['Content-Type: text/plain', 'Title: ' . ($params['title'] ?? 'New Article')]; + + if (!empty($token)) { + $headers[] = 'Authorization: Bearer ' . $token; + } + + if (!empty($params['url'])) { + $headers[] = 'Click: ' . $params['url']; + } + + $ch = curl_init($serverUrl . '/' . $topic); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $message, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: ['raw' => $response]; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['topic'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Ntfy Push Notifications']; + } + + public function getSupportedMediaTypes(): array + { + return []; + } +} diff --git a/src/packages/plg_mokojoomcross_ntfy/src/Extension/index.html b/src/packages/plg_mokojoomcross_ntfy/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_ntfy/src/index.html b/src/packages/plg_mokojoomcross_ntfy/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_ntfy/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/index.html b/src/packages/plg_mokojoomcross_pinterest/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/language/en-GB/index.html b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.ini b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.ini new file mode 100644 index 0000000..c0cba65 --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_PINTEREST="MokoJoomCross - Pinterest" +PLG_MOKOJOOMCROSS_PINTEREST_DESCRIPTION="Cross-post Joomla articles to Pinterest." diff --git a/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.sys.ini b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.sys.ini new file mode 100644 index 0000000..c0cba65 --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/language/en-GB/plg_mokojoomcross_pinterest.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_PINTEREST="MokoJoomCross - Pinterest" +PLG_MOKOJOOMCROSS_PINTEREST_DESCRIPTION="Cross-post Joomla articles to Pinterest." diff --git a/src/packages/plg_mokojoomcross_pinterest/language/index.html b/src/packages/plg_mokojoomcross_pinterest/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/pinterest.php b/src/packages/plg_mokojoomcross_pinterest/pinterest.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/pinterest.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_pinterest/pinterest.xml b/src/packages/plg_mokojoomcross_pinterest/pinterest.xml new file mode 100644 index 0000000..346e17c --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/pinterest.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Pinterest + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_PINTEREST_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Pinterest + + + pinterest.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_pinterest.ini + language/en-GB/plg_mokojoomcross_pinterest.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_pinterest/services/index.html b/src/packages/plg_mokojoomcross_pinterest/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/services/provider.php b/src/packages/plg_mokojoomcross_pinterest/services/provider.php new file mode 100644 index 0000000..430b849 --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Pinterest\Extension\PinterestService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new PinterestService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'pinterest') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_pinterest/src/Extension/PinterestService.php b/src/packages/plg_mokojoomcross_pinterest/src/Extension/PinterestService.php new file mode 100644 index 0000000..9aa07eb --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/src/Extension/PinterestService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Pinterest\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Pinterest service plugin for MokoJoomCross. + * + * API: https://api.pinterest.com/v5/pins + */ +class PinterestService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'pinterest'; } + public function getServiceName(): string { return 'Pinterest'; } + public function getMaxLength(): int { return 500; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://api.pinterest.com/v5/pins', + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Pinterest']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_pinterest/src/Extension/index.html b/src/packages/plg_mokojoomcross_pinterest/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_pinterest/src/index.html b/src/packages/plg_mokojoomcross_pinterest/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_pinterest/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/index.html b/src/packages/plg_mokojoomcross_reddit/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/language/en-GB/index.html b/src/packages/plg_mokojoomcross_reddit/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.ini b/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.ini new file mode 100644 index 0000000..c6d19ab --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_REDDIT="MokoJoomCross - Reddit" +PLG_MOKOJOOMCROSS_REDDIT_DESCRIPTION="Cross-post Joomla articles to Reddit." diff --git a/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.sys.ini b/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.sys.ini new file mode 100644 index 0000000..c6d19ab --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/language/en-GB/plg_mokojoomcross_reddit.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_REDDIT="MokoJoomCross - Reddit" +PLG_MOKOJOOMCROSS_REDDIT_DESCRIPTION="Cross-post Joomla articles to Reddit." diff --git a/src/packages/plg_mokojoomcross_reddit/language/index.html b/src/packages/plg_mokojoomcross_reddit/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/reddit.php b/src/packages/plg_mokojoomcross_reddit/reddit.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/reddit.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_reddit/reddit.xml b/src/packages/plg_mokojoomcross_reddit/reddit.xml new file mode 100644 index 0000000..d48360e --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/reddit.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Reddit + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_REDDIT_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Reddit + + + reddit.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_reddit.ini + language/en-GB/plg_mokojoomcross_reddit.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_reddit/services/index.html b/src/packages/plg_mokojoomcross_reddit/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/services/provider.php b/src/packages/plg_mokojoomcross_reddit/services/provider.php new file mode 100644 index 0000000..e67a5c7 --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Reddit\Extension\RedditService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new RedditService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'reddit') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_reddit/src/Extension/RedditService.php b/src/packages/plg_mokojoomcross_reddit/src/Extension/RedditService.php new file mode 100644 index 0000000..9b9657d --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/src/Extension/RedditService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Reddit\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Reddit service plugin for MokoJoomCross. + * + * API: https://oauth.reddit.com/api/submit + */ +class RedditService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'reddit'; } + public function getServiceName(): string { return 'Reddit'; } + public function getMaxLength(): int { return 300; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['client_id'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['client_id'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://oauth.reddit.com/api/submit', + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['client_id'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Reddit']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_reddit/src/Extension/index.html b/src/packages/plg_mokojoomcross_reddit/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_reddit/src/index.html b/src/packages/plg_mokojoomcross_reddit/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_reddit/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/index.html b/src/packages/plg_mokojoomcross_rssfeed/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/index.html b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.ini b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.ini new file mode 100644 index 0000000..6228eed --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_RSSFEED="MokoJoomCross - RSS Feed" +PLG_MOKOJOOMCROSS_RSSFEED_DESCRIPTION="Cross-post Joomla articles to RSS Feed." diff --git a/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.sys.ini b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.sys.ini new file mode 100644 index 0000000..6228eed --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/language/en-GB/plg_mokojoomcross_rssfeed.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_RSSFEED="MokoJoomCross - RSS Feed" +PLG_MOKOJOOMCROSS_RSSFEED_DESCRIPTION="Cross-post Joomla articles to RSS Feed." diff --git a/src/packages/plg_mokojoomcross_rssfeed/language/index.html b/src/packages/plg_mokojoomcross_rssfeed/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/rssfeed.php b/src/packages/plg_mokojoomcross_rssfeed/rssfeed.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/rssfeed.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_rssfeed/rssfeed.xml b/src/packages/plg_mokojoomcross_rssfeed/rssfeed.xml new file mode 100644 index 0000000..f443185 --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/rssfeed.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - RSS Feed + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_RSSFEED_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Rssfeed + + + rssfeed.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_rssfeed.ini + language/en-GB/plg_mokojoomcross_rssfeed.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_rssfeed/services/index.html b/src/packages/plg_mokojoomcross_rssfeed/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/services/provider.php b/src/packages/plg_mokojoomcross_rssfeed/services/provider.php new file mode 100644 index 0000000..dec6789 --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Rssfeed\Extension\RssfeedService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new RssfeedService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'rssfeed') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_rssfeed/src/Extension/RssfeedService.php b/src/packages/plg_mokojoomcross_rssfeed/src/Extension/RssfeedService.php new file mode 100644 index 0000000..3ffaf98 --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/src/Extension/RssfeedService.php @@ -0,0 +1,66 @@ + + * @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\MokoJoomCross\Rssfeed\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * RSS Feed service plugin for MokoJoomCross. + * + * This is a local service — it doesn't call an external API. + * When an article is "published" to the RSS feed service, it simply + * marks the post as successful. The feed view reads from the posts + * table to generate the RSS/Atom XML output. + */ +class RssfeedService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'rssfeed'; } + public function getServiceName(): string { return 'RSS Feed'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + // RSS Feed is a local service — no external API call needed. + // The post record in the queue table serves as the feed data source. + return [ + 'success' => true, + 'platform_post_id' => 'feed-' . time(), + 'response' => ['type' => 'rss_feed'], + ]; + } + + public function validateCredentials(array $credentials): array + { + // No credentials required for local RSS feed generation. + return ['valid' => true, 'message' => 'RSS feed is a local service — no credentials needed.', 'account_name' => 'RSS Feed']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_rssfeed/src/Extension/index.html b/src/packages/plg_mokojoomcross_rssfeed/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_rssfeed/src/index.html b/src/packages/plg_mokojoomcross_rssfeed/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_rssfeed/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/index.html b/src/packages/plg_mokojoomcross_sendgrid/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/index.html b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.ini b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.ini new file mode 100644 index 0000000..5ea1c3f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_SENDGRID="MokoJoomCross - SendGrid" +PLG_MOKOJOOMCROSS_SENDGRID_DESCRIPTION="Cross-post Joomla articles to SendGrid." diff --git a/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.sys.ini b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.sys.ini new file mode 100644 index 0000000..5ea1c3f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/language/en-GB/plg_mokojoomcross_sendgrid.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_SENDGRID="MokoJoomCross - SendGrid" +PLG_MOKOJOOMCROSS_SENDGRID_DESCRIPTION="Cross-post Joomla articles to SendGrid." diff --git a/src/packages/plg_mokojoomcross_sendgrid/language/index.html b/src/packages/plg_mokojoomcross_sendgrid/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/sendgrid.php b/src/packages/plg_mokojoomcross_sendgrid/sendgrid.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/sendgrid.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_sendgrid/sendgrid.xml b/src/packages/plg_mokojoomcross_sendgrid/sendgrid.xml new file mode 100644 index 0000000..18d1cfe --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/sendgrid.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - SendGrid + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_SENDGRID_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Sendgrid + + + sendgrid.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_sendgrid.ini + language/en-GB/plg_mokojoomcross_sendgrid.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_sendgrid/services/index.html b/src/packages/plg_mokojoomcross_sendgrid/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/services/provider.php b/src/packages/plg_mokojoomcross_sendgrid/services/provider.php new file mode 100644 index 0000000..14c5d10 --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Sendgrid\Extension\SendgridService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new SendgridService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'sendgrid') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_sendgrid/src/Extension/SendgridService.php b/src/packages/plg_mokojoomcross_sendgrid/src/Extension/SendgridService.php new file mode 100644 index 0000000..b38c232 --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/src/Extension/SendgridService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Sendgrid\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * SendGrid service plugin for MokoJoomCross. + * + * API: https://api.sendgrid.com/v3/marketing/singlesends + */ +class SendgridService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'sendgrid'; } + public function getServiceName(): string { return 'SendGrid'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['api_key'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://api.sendgrid.com/v3/marketing/singlesends', + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['api_key'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'SendGrid']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_sendgrid/src/Extension/index.html b/src/packages/plg_mokojoomcross_sendgrid/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_sendgrid/src/index.html b/src/packages/plg_mokojoomcross_sendgrid/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_sendgrid/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_slack/language/en-GB/plg_mokojoomcross_slack.ini b/src/packages/plg_mokojoomcross_slack/language/en-GB/plg_mokojoomcross_slack.ini index e6dbc44..d146b40 100644 --- a/src/packages/plg_mokojoomcross_slack/language/en-GB/plg_mokojoomcross_slack.ini +++ b/src/packages/plg_mokojoomcross_slack/language/en-GB/plg_mokojoomcross_slack.ini @@ -1,2 +1,5 @@ PLG_MOKOJOOMCROSS_SLACK="MokoJoomCross - Slack" PLG_MOKOJOOMCROSS_SLACK_DESCRIPTION="Cross-post Joomla articles to Slack." +PLG_MOKOJOOMCROSS_SLACK_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOJOOMCROSS_SLACK_DEFAULT_WEBHOOK_URL="Default Webhook URL" +PLG_MOKOJOOMCROSS_SLACK_DEFAULT_WEBHOOK_URL_DESC="The default MokoWaaS Slack webhook URL used when a service is set to 'default' mode." diff --git a/src/packages/plg_mokojoomcross_slack/services/provider.php b/src/packages/plg_mokojoomcross_slack/services/provider.php index 28b9e2d..c62daf2 100644 --- a/src/packages/plg_mokojoomcross_slack/services/provider.php +++ b/src/packages/plg_mokojoomcross_slack/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Slack\Extension\SlackService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_slack/slack.xml b/src/packages/plg_mokojoomcross_slack/slack.xml index 100500d..2b2c474 100644 --- a/src/packages/plg_mokojoomcross_slack/slack.xml +++ b/src/packages/plg_mokojoomcross_slack/slack.xml @@ -1,7 +1,7 @@ MokoJoomCross - Slack - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -23,4 +23,17 @@ language/en-GB/plg_mokojoomcross_slack.ini language/en-GB/plg_mokojoomcross_slack.sys.ini + + + +
+ +
+
+
diff --git a/src/packages/plg_mokojoomcross_slack/src/Extension/SlackService.php b/src/packages/plg_mokojoomcross_slack/src/Extension/SlackService.php index 66519a5..c9e8455 100644 --- a/src/packages/plg_mokojoomcross_slack/src/Extension/SlackService.php +++ b/src/packages/plg_mokojoomcross_slack/src/Extension/SlackService.php @@ -111,7 +111,11 @@ class SlackService extends CMSPlugin implements SubscriberInterface, MokoJoomCro return $credentials['webhook_url'] ?? ''; } - return \Joomla\CMS\Component\ComponentHelper::getParams('com_mokojoomcross') - ->get('slack_default_webhook', ''); + return $this->params->get('default_webhook_url', ''); + } + + public function getSupportedMediaTypes(): array + { + return ['image']; } } diff --git a/src/packages/plg_mokojoomcross_teams/index.html b/src/packages/plg_mokojoomcross_teams/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/language/en-GB/index.html b/src/packages/plg_mokojoomcross_teams/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.ini b/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.ini new file mode 100644 index 0000000..fa67751 --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.ini @@ -0,0 +1,5 @@ +PLG_MOKOJOOMCROSS_TEAMS="MokoJoomCross - Microsoft Teams" +PLG_MOKOJOOMCROSS_TEAMS_DESCRIPTION="Cross-post Joomla articles to Microsoft Teams." +PLG_MOKOJOOMCROSS_TEAMS_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOJOOMCROSS_TEAMS_DEFAULT_WEBHOOK="Default Webhook URL" +PLG_MOKOJOOMCROSS_TEAMS_DEFAULT_WEBHOOK_DESC="Pre-configured MokoWaaS webhook URL. Services using default mode will use this URL." diff --git a/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.sys.ini b/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.sys.ini new file mode 100644 index 0000000..147a495 --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/language/en-GB/plg_mokojoomcross_teams.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_TEAMS="MokoJoomCross - Microsoft Teams" +PLG_MOKOJOOMCROSS_TEAMS_DESCRIPTION="Cross-post Joomla articles to Microsoft Teams." diff --git a/src/packages/plg_mokojoomcross_teams/language/index.html b/src/packages/plg_mokojoomcross_teams/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/services/index.html b/src/packages/plg_mokojoomcross_teams/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/services/provider.php b/src/packages/plg_mokojoomcross_teams/services/provider.php new file mode 100644 index 0000000..31dd50e --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Teams\Extension\TeamsService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new TeamsService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'teams') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_teams/src/Extension/TeamsService.php b/src/packages/plg_mokojoomcross_teams/src/Extension/TeamsService.php new file mode 100644 index 0000000..45ef460 --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/src/Extension/TeamsService.php @@ -0,0 +1,104 @@ + + * @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\MokoJoomCross\Teams\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Microsoft Teams service plugin for MokoJoomCross. + * + * API: https://outlook.office.com/webhook/... + */ +class TeamsService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'teams'; } + public function getServiceName(): string { return 'Microsoft Teams'; } + public function getMaxLength(): int { return 28000; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($url)) { + $url = $this->params->get('default_webhook_url', ''); + } + + if (empty($url)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing webhook URL']]; + } + + $postData = json_encode(['text' => $message]); + + $ch = curl_init($url); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: ['raw' => $response]; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Microsoft Teams']; + } + private function resolveCredential(array $credentials, string $key): string + { + $mode = $credentials['mode'] ?? 'default'; + + if ($mode === 'custom') { + return $credentials[$key] ?? ''; + } + + return $this->params->get('default_webhook_url', ''); + } + + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_teams/src/Extension/index.html b/src/packages/plg_mokojoomcross_teams/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/src/index.html b/src/packages/plg_mokojoomcross_teams/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_teams/teams.php b/src/packages/plg_mokojoomcross_teams/teams.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/teams.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_teams/teams.xml b/src/packages/plg_mokojoomcross_teams/teams.xml new file mode 100644 index 0000000..f09243d --- /dev/null +++ b/src/packages/plg_mokojoomcross_teams/teams.xml @@ -0,0 +1,39 @@ + + + MokoJoomCross - Microsoft Teams + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_TEAMS_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Teams + + + teams.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_teams.ini + language/en-GB/plg_mokojoomcross_teams.sys.ini + + + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_telegram/language/en-GB/plg_mokojoomcross_telegram.ini b/src/packages/plg_mokojoomcross_telegram/language/en-GB/plg_mokojoomcross_telegram.ini index 2b2467f..3754099 100644 --- a/src/packages/plg_mokojoomcross_telegram/language/en-GB/plg_mokojoomcross_telegram.ini +++ b/src/packages/plg_mokojoomcross_telegram/language/en-GB/plg_mokojoomcross_telegram.ini @@ -1,2 +1,9 @@ PLG_MOKOJOOMCROSS_TELEGRAM="MokoJoomCross - Telegram" -PLG_MOKOJOOMCROSS_TELEGRAM_DESCRIPTION="Cross-post Joomla articles to Telegram." +PLG_MOKOJOOMCROSS_TELEGRAM_DESCRIPTION="Cross-post Joomla articles to Telegram channels and groups. Supports default @MokoWaaSBot and custom bot modes." +PLG_MOKOJOOMCROSS_TELEGRAM_FIELDSET_DEFAULTS="Default Bot Settings" +PLG_MOKOJOOMCROSS_TELEGRAM_DEFAULT_BOT_TOKEN="Default Bot Token" +PLG_MOKOJOOMCROSS_TELEGRAM_DEFAULT_BOT_TOKEN_DESC="Bot API token for the default MokoWaaS bot. Services using 'default' mode will use this token. Leave empty to require custom tokens on each service." +PLG_MOKOJOOMCROSS_TELEGRAM_PARSE_MODE="Message Format" +PLG_MOKOJOOMCROSS_TELEGRAM_PARSE_MODE_DESC="How Telegram parses formatting in messages." +PLG_MOKOJOOMCROSS_TELEGRAM_DISABLE_PREVIEW="Disable Link Preview" +PLG_MOKOJOOMCROSS_TELEGRAM_DISABLE_PREVIEW_DESC="Disable automatic link preview in Telegram messages." diff --git a/src/packages/plg_mokojoomcross_telegram/services/provider.php b/src/packages/plg_mokojoomcross_telegram/services/provider.php index da7a416..9893c26 100644 --- a/src/packages/plg_mokojoomcross_telegram/services/provider.php +++ b/src/packages/plg_mokojoomcross_telegram/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Telegram\Extension\TelegramService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_telegram/src/Extension/TelegramService.php b/src/packages/plg_mokojoomcross_telegram/src/Extension/TelegramService.php index 1f44a36..9fa27f4 100644 --- a/src/packages/plg_mokojoomcross_telegram/src/Extension/TelegramService.php +++ b/src/packages/plg_mokojoomcross_telegram/src/Extension/TelegramService.php @@ -181,9 +181,12 @@ class TelegramService extends CMSPlugin implements SubscriberInterface, MokoJoom return $credentials['bot_token'] ?? ''; } - // Default mode — load from component encrypted params - $componentParams = \Joomla\CMS\Component\ComponentHelper::getParams('com_mokojoomcross'); + // Default mode — load from plugin params (set in Extensions → Plugins → MokoJoomCross - Telegram) + return $this->params->get('default_bot_token', ''); + } - return $componentParams->get('telegram_default_bot_token', ''); + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'document']; } } diff --git a/src/packages/plg_mokojoomcross_telegram/telegram.xml b/src/packages/plg_mokojoomcross_telegram/telegram.xml index a8616a6..42090c7 100644 --- a/src/packages/plg_mokojoomcross_telegram/telegram.xml +++ b/src/packages/plg_mokojoomcross_telegram/telegram.xml @@ -1,7 +1,7 @@ MokoJoomCross - Telegram - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_mokojoomcross_threads/index.html b/src/packages/plg_mokojoomcross_threads/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/language/en-GB/index.html b/src/packages/plg_mokojoomcross_threads/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.ini b/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.ini new file mode 100644 index 0000000..b0fc74d --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.ini @@ -0,0 +1,5 @@ +PLG_MOKOJOOMCROSS_THREADS="MokoJoomCross - Threads (Meta)" +PLG_MOKOJOOMCROSS_THREADS_DESCRIPTION="Cross-post Joomla articles to Threads (Meta)." +PLG_MOKOJOOMCROSS_THREADS_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOJOOMCROSS_THREADS_DEFAULT_WEBHOOK="Default Webhook URL" +PLG_MOKOJOOMCROSS_THREADS_DEFAULT_WEBHOOK_DESC="Pre-configured MokoWaaS webhook URL. Services using default mode will use this URL." diff --git a/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.sys.ini b/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.sys.ini new file mode 100644 index 0000000..081da58 --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/language/en-GB/plg_mokojoomcross_threads.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_THREADS="MokoJoomCross - Threads (Meta)" +PLG_MOKOJOOMCROSS_THREADS_DESCRIPTION="Cross-post Joomla articles to Threads (Meta)." diff --git a/src/packages/plg_mokojoomcross_threads/language/index.html b/src/packages/plg_mokojoomcross_threads/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/services/index.html b/src/packages/plg_mokojoomcross_threads/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/services/provider.php b/src/packages/plg_mokojoomcross_threads/services/provider.php new file mode 100644 index 0000000..a4ea3b2 --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Threads\Extension\ThreadsService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new ThreadsService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'threads') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_threads/src/Extension/ThreadsService.php b/src/packages/plg_mokojoomcross_threads/src/Extension/ThreadsService.php new file mode 100644 index 0000000..6f2022e --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/src/Extension/ThreadsService.php @@ -0,0 +1,151 @@ + + * @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\MokoJoomCross\Threads\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Threads (Meta) service plugin for MokoJoomCross. + * + * Uses the Threads Publishing API — a 2-step flow: + * 1. Create a media container via POST /{user_id}/threads + * 2. Publish the container via POST /{user_id}/threads_publish + */ +class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'threads'; } + public function getServiceName(): string { return 'Threads (Meta)'; } + public function getMaxLength(): int { return 500; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $this->resolveCredential($credentials, 'access_token'); + $userId = $credentials['user_id'] ?? ''; + + if (empty($token) || empty($userId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or user ID.']]; + } + + // Step 1: Create media container + $containerUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads'; + $containerData = [ + 'media_type' => 'TEXT', + 'text' => mb_substr($message, 0, 500), + 'access_token' => $token, + ]; + + $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); + $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.threads.net/v1.0/' . urlencode($userId) . '/threads_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); + $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'); + $userId = $credentials['user_id'] ?? ''; + + if (empty($token) || empty($userId)) { + return ['valid' => false, 'message' => 'Access token and user ID are required.', 'account_name' => '']; + } + + $ch = curl_init('https://graph.threads.net/v1.0/' . urlencode($userId) . '?fields=username&access_token=' . urlencode($token)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['username'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $data['username']]; + } + + 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']; + } +} diff --git a/src/packages/plg_mokojoomcross_threads/src/Extension/index.html b/src/packages/plg_mokojoomcross_threads/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/src/index.html b/src/packages/plg_mokojoomcross_threads/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_threads/threads.php b/src/packages/plg_mokojoomcross_threads/threads.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/threads.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_threads/threads.xml b/src/packages/plg_mokojoomcross_threads/threads.xml new file mode 100644 index 0000000..cc915a9 --- /dev/null +++ b/src/packages/plg_mokojoomcross_threads/threads.xml @@ -0,0 +1,39 @@ + + + MokoJoomCross - Threads (Meta) + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_THREADS_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Threads + + + threads.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_threads.ini + language/en-GB/plg_mokojoomcross_threads.sys.ini + + + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_tiktok/index.html b/src/packages/plg_mokojoomcross_tiktok/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/language/en-GB/index.html b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.ini b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.ini new file mode 100644 index 0000000..c7ba230 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_TIKTOK="MokoJoomCross - TikTok" +PLG_MOKOJOOMCROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok." diff --git a/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.sys.ini b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.sys.ini new file mode 100644 index 0000000..c7ba230 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/language/en-GB/plg_mokojoomcross_tiktok.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_TIKTOK="MokoJoomCross - TikTok" +PLG_MOKOJOOMCROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok." diff --git a/src/packages/plg_mokojoomcross_tiktok/language/index.html b/src/packages/plg_mokojoomcross_tiktok/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/services/index.html b/src/packages/plg_mokojoomcross_tiktok/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/services/provider.php b/src/packages/plg_mokojoomcross_tiktok/services/provider.php new file mode 100644 index 0000000..597a887 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Tiktok\Extension\TiktokService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new TiktokService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'tiktok') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_tiktok/src/Extension/TiktokService.php b/src/packages/plg_mokojoomcross_tiktok/src/Extension/TiktokService.php new file mode 100644 index 0000000..0b17615 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/src/Extension/TiktokService.php @@ -0,0 +1,92 @@ + + * @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\MokoJoomCross\Tiktok\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * TikTok service plugin for MokoJoomCross. + * + * API: https://open.tiktokapis.com/v2/post/publish/content/init/ + */ +class TiktokService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'tiktok'; } + public function getServiceName(): string { return 'TikTok'; } + public function getMaxLength(): int { return 2200; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $url = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']]; + } + + $postData = json_encode(['content' => $message]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/', + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $key = $credentials['access_token'] ?? $credentials['webhook_url'] ?? ''; + + if (empty($key)) { + return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'TikTok']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/src/packages/plg_mokojoomcross_tiktok/src/Extension/index.html b/src/packages/plg_mokojoomcross_tiktok/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/src/index.html b/src/packages/plg_mokojoomcross_tiktok/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tiktok/tiktok.php b/src/packages/plg_mokojoomcross_tiktok/tiktok.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/tiktok.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_tiktok/tiktok.xml b/src/packages/plg_mokojoomcross_tiktok/tiktok.xml new file mode 100644 index 0000000..add1f3f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tiktok/tiktok.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - TikTok + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_TIKTOK_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Tiktok + + + tiktok.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_tiktok.ini + language/en-GB/plg_mokojoomcross_tiktok.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_tumblr/index.html b/src/packages/plg_mokojoomcross_tumblr/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/language/en-GB/index.html b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.ini b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.ini new file mode 100644 index 0000000..8a7e56e --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_TUMBLR="MokoJoomCross - Tumblr" +PLG_MOKOJOOMCROSS_TUMBLR_DESCRIPTION="Cross-post Joomla articles to Tumblr." diff --git a/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.sys.ini b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.sys.ini new file mode 100644 index 0000000..8a7e56e --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/language/en-GB/plg_mokojoomcross_tumblr.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_TUMBLR="MokoJoomCross - Tumblr" +PLG_MOKOJOOMCROSS_TUMBLR_DESCRIPTION="Cross-post Joomla articles to Tumblr." diff --git a/src/packages/plg_mokojoomcross_tumblr/language/index.html b/src/packages/plg_mokojoomcross_tumblr/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/services/index.html b/src/packages/plg_mokojoomcross_tumblr/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/services/provider.php b/src/packages/plg_mokojoomcross_tumblr/services/provider.php new file mode 100644 index 0000000..ddd1cfb --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Tumblr\Extension\TumblrService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new TumblrService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'tumblr') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_tumblr/src/Extension/TumblrService.php b/src/packages/plg_mokojoomcross_tumblr/src/Extension/TumblrService.php new file mode 100644 index 0000000..421062f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/src/Extension/TumblrService.php @@ -0,0 +1,118 @@ + + * @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\MokoJoomCross\Tumblr\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Tumblr service plugin for MokoJoomCross. + * + * Uses the Tumblr API v2 with OAuth Bearer token to create link posts. + */ +class TumblrService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'tumblr'; } + public function getServiceName(): string { return 'Tumblr'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + $blogName = $credentials['blog_name'] ?? ''; + + if (empty($token) || empty($blogName)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or blog name.']]; + } + + $apiUrl = 'https://api.tumblr.com/v2/blog/' . urlencode($blogName) . '/post'; + $payload = json_encode([ + 'type' => 'text', + 'title' => mb_substr(strip_tags($message), 0, 150), + 'body' => $message, + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + $postId = $data['response']['id'] ?? ''; + + if ($httpCode === 201 && !empty($postId)) { + return ['success' => true, 'platform_post_id' => (string) $postId, 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['access_token'] ?? ''; + $blogName = $credentials['blog_name'] ?? ''; + + if (empty($token) || empty($blogName)) { + return ['valid' => false, 'message' => 'Access token and blog name are required.', 'account_name' => '']; + } + + $ch = curl_init('https://api.tumblr.com/v2/blog/' . urlencode($blogName) . '/info'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + $name = $data['response']['blog']['title'] ?? $data['response']['blog']['name'] ?? ''; + + if (!empty($name)) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $name]; + } + + return ['valid' => false, 'message' => $data['meta']['msg'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'gif']; + } +} diff --git a/src/packages/plg_mokojoomcross_tumblr/src/Extension/index.html b/src/packages/plg_mokojoomcross_tumblr/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/src/index.html b/src/packages/plg_mokojoomcross_tumblr/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_tumblr/tumblr.php b/src/packages/plg_mokojoomcross_tumblr/tumblr.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/tumblr.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_tumblr/tumblr.xml b/src/packages/plg_mokojoomcross_tumblr/tumblr.xml new file mode 100644 index 0000000..2cdfbb8 --- /dev/null +++ b/src/packages/plg_mokojoomcross_tumblr/tumblr.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Tumblr + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_TUMBLR_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Tumblr + + + tumblr.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_tumblr.ini + language/en-GB/plg_mokojoomcross_tumblr.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_twitter/services/provider.php b/src/packages/plg_mokojoomcross_twitter/services/provider.php index 9e21213..765bc17 100644 --- a/src/packages/plg_mokojoomcross_twitter/services/provider.php +++ b/src/packages/plg_mokojoomcross_twitter/services/provider.php @@ -17,7 +17,7 @@ use Joomla\CMS\Plugin\PluginHelper; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Plugin\MokoJoomCross${CLASS_NAME}\Extension${CLASS_NAME}Service; +use Joomla\Plugin\MokoJoomCross\Twitter\Extension\TwitterService; return new class () implements ServiceProviderInterface { public function register(Container $container): void diff --git a/src/packages/plg_mokojoomcross_twitter/src/Extension/TwitterService.php b/src/packages/plg_mokojoomcross_twitter/src/Extension/TwitterService.php index 8911af2..f1c0f8f 100644 --- a/src/packages/plg_mokojoomcross_twitter/src/Extension/TwitterService.php +++ b/src/packages/plg_mokojoomcross_twitter/src/Extension/TwitterService.php @@ -20,16 +20,9 @@ use Joomla\Event\SubscriberInterface; /** * X/Twitter service plugin for MokoJoomCross. * - * Uses Twitter API v2 with OAuth 2.0 Bearer Token. - * - * Credentials format: - * { - * "bearer_token": "...", - * "api_key": "...", - * "api_secret": "...", - * "access_token": "...", - * "access_token_secret": "..." - * } + * Uses Twitter API v2 with OAuth 1.0a (HMAC-SHA1) for posting. + * Bearer tokens are app-only and cannot create tweets — OAuth 1.0a + * with consumer key/secret + access token/secret is required. */ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface { @@ -57,18 +50,31 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoJoomC public function publish(string $message, array $media, array $credentials, array $params): array { - // Twitter API v2 tweet creation - $apiUrl = 'https://api.twitter.com/2/tweets'; - + $apiUrl = 'https://api.twitter.com/2/tweets'; $postData = json_encode(['text' => mb_substr($message, 0, 280)]); + $consumerKey = $credentials['api_key'] ?? ''; + $consumerSecret = $credentials['api_secret'] ?? ''; + $accessToken = $credentials['access_token'] ?? ''; + $tokenSecret = $credentials['access_token_secret'] ?? ''; + + if (!$consumerKey || !$consumerSecret || !$accessToken || !$tokenSecret) { + return [ + 'success' => false, + 'platform_post_id' => '', + 'response' => ['error' => 'Missing OAuth 1.0a credentials. All 4 keys are required.'], + ]; + } + + $authHeader = $this->buildOAuth1Header('POST', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + $ch = curl_init($apiUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', - 'Authorization: Bearer ' . ($credentials['bearer_token'] ?? ''), + 'Authorization: ' . $authHeader, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, @@ -91,9 +97,20 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoJoomC { $apiUrl = 'https://api.twitter.com/2/users/me'; + $consumerKey = $credentials['api_key'] ?? ''; + $consumerSecret = $credentials['api_secret'] ?? ''; + $accessToken = $credentials['access_token'] ?? ''; + $tokenSecret = $credentials['access_token_secret'] ?? ''; + + if (!$consumerKey || !$consumerSecret || !$accessToken || !$tokenSecret) { + return ['valid' => false, 'message' => 'All 4 OAuth keys are required.', 'account_name' => '']; + } + + $authHeader = $this->buildOAuth1Header('GET', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + $ch = curl_init($apiUrl); curl_setopt_array($ch, [ - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . ($credentials['bearer_token'] ?? '')], + CURLOPT_HTTPHEADER => ['Authorization: ' . $authHeader], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); @@ -119,4 +136,55 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoJoomC { return true; } + + /** + * Build an OAuth 1.0a Authorization header with HMAC-SHA1 signature. + */ + private function buildOAuth1Header( + string $method, + string $url, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): string { + $oauthParams = [ + 'oauth_consumer_key' => $consumerKey, + 'oauth_nonce' => bin2hex(random_bytes(16)), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => (string) time(), + 'oauth_token' => $accessToken, + 'oauth_version' => '1.0', + ]; + + // Build signature base string: METHOD&URL¶ms (all percent-encoded) + ksort($oauthParams); + + $paramString = http_build_query($oauthParams, '', '&', PHP_QUERY_RFC3986); + + $baseString = strtoupper($method) . '&' + . rawurlencode($url) . '&' + . rawurlencode($paramString); + + // Signing key: consumer_secret&token_secret (both percent-encoded) + $signingKey = rawurlencode($consumerSecret) . '&' . rawurlencode($tokenSecret); + + $oauthParams['oauth_signature'] = base64_encode( + hash_hmac('sha1', $baseString, $signingKey, true) + ); + + // Build Authorization header + $parts = []; + + foreach ($oauthParams as $key => $value) { + $parts[] = rawurlencode($key) . '="' . rawurlencode($value) . '"'; + } + + return 'OAuth ' . implode(', ', $parts); + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'gif']; + } } diff --git a/src/packages/plg_mokojoomcross_twitter/twitter.xml b/src/packages/plg_mokojoomcross_twitter/twitter.xml index 69418c8..df188ee 100644 --- a/src/packages/plg_mokojoomcross_twitter/twitter.xml +++ b/src/packages/plg_mokojoomcross_twitter/twitter.xml @@ -1,7 +1,7 @@ MokoJoomCross - X / Twitter - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_mokojoomcross_webhook/index.html b/src/packages/plg_mokojoomcross_webhook/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/language/en-GB/index.html b/src/packages/plg_mokojoomcross_webhook/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.ini b/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.ini new file mode 100644 index 0000000..5f8cf2a --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WEBHOOK="MokoJoomCross - Generic Webhook" +PLG_MOKOJOOMCROSS_WEBHOOK_DESCRIPTION="Cross-post Joomla articles to Generic Webhook." diff --git a/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.sys.ini b/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.sys.ini new file mode 100644 index 0000000..5f8cf2a --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/language/en-GB/plg_mokojoomcross_webhook.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WEBHOOK="MokoJoomCross - Generic Webhook" +PLG_MOKOJOOMCROSS_WEBHOOK_DESCRIPTION="Cross-post Joomla articles to Generic Webhook." diff --git a/src/packages/plg_mokojoomcross_webhook/language/index.html b/src/packages/plg_mokojoomcross_webhook/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/services/index.html b/src/packages/plg_mokojoomcross_webhook/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/services/provider.php b/src/packages/plg_mokojoomcross_webhook/services/provider.php new file mode 100644 index 0000000..b4cfbdb --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Webhook\Extension\WebhookService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new WebhookService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'webhook') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_webhook/src/Extension/WebhookService.php b/src/packages/plg_mokojoomcross_webhook/src/Extension/WebhookService.php new file mode 100644 index 0000000..4767e16 --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/src/Extension/WebhookService.php @@ -0,0 +1,121 @@ + + * @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\MokoJoomCross\Webhook\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Generic Webhook service plugin for MokoJoomCross. + * + * API: configured webhook URL + */ +class WebhookService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'webhook'; } + public function getServiceName(): string { return 'Generic Webhook'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + // Credential keys match the service.xml form field names (after stripping cred_webhook_ prefix): + // url, method, auth_type, bearer_token, basic_username, basic_password, content_type + $url = $credentials['url'] ?? ''; + + if (empty($url)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing webhook URL']]; + } + + $method = $credentials['method'] ?? 'POST'; + $format = $credentials['content_type'] ?? 'json'; + + $payload = [ + 'title' => $params['title'] ?? '', + 'url' => $params['_article_url'] ?? $params['url'] ?? '', + 'message' => $message, + 'image' => $params['image'] ?? '', + 'category' => $params['category'] ?? '', + 'author' => $params['author'] ?? '', + 'timestamp' => date('c'), + ]; + + $httpHeaders = ['Content-Type: application/json']; + + $body = ($format === 'form') ? http_build_query($payload) : json_encode($payload); + + if ($format === 'form') { + $httpHeaders[0] = 'Content-Type: application/x-www-form-urlencoded'; + } + + // Apply authentication based on auth_type + $authType = $credentials['auth_type'] ?? 'none'; + + if ($authType === 'bearer' && !empty($credentials['bearer_token'])) { + $httpHeaders[] = 'Authorization: Bearer ' . $credentials['bearer_token']; + } elseif ($authType === 'basic' && !empty($credentials['basic_username'])) { + $httpHeaders[] = 'Authorization: Basic ' . base64_encode( + $credentials['basic_username'] . ':' . ($credentials['basic_password'] ?? '') + ); + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $httpHeaders, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: ['raw' => $response]; + + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $url = $credentials['url'] ?? ''; + + if (empty($url)) { + return ['valid' => false, 'message' => 'Missing webhook URL', 'account_name' => '']; + } + + return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Generic Webhook']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'document']; + } +} diff --git a/src/packages/plg_mokojoomcross_webhook/src/Extension/index.html b/src/packages/plg_mokojoomcross_webhook/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/src/index.html b/src/packages/plg_mokojoomcross_webhook/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_webhook/webhook.php b/src/packages/plg_mokojoomcross_webhook/webhook.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/webhook.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_webhook/webhook.xml b/src/packages/plg_mokojoomcross_webhook/webhook.xml new file mode 100644 index 0000000..9ab8c0c --- /dev/null +++ b/src/packages/plg_mokojoomcross_webhook/webhook.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - Generic Webhook + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_WEBHOOK_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Webhook + + + webhook.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_webhook.ini + language/en-GB/plg_mokojoomcross_webhook.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_whatsapp/index.html b/src/packages/plg_mokojoomcross_whatsapp/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/index.html b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.ini b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.ini new file mode 100644 index 0000000..3a55b87 --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WHATSAPP="MokoJoomCross - WhatsApp Business" +PLG_MOKOJOOMCROSS_WHATSAPP_DESCRIPTION="Cross-post Joomla articles to WhatsApp Business." diff --git a/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.sys.ini b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.sys.ini new file mode 100644 index 0000000..3a55b87 --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/language/en-GB/plg_mokojoomcross_whatsapp.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WHATSAPP="MokoJoomCross - WhatsApp Business" +PLG_MOKOJOOMCROSS_WHATSAPP_DESCRIPTION="Cross-post Joomla articles to WhatsApp Business." diff --git a/src/packages/plg_mokojoomcross_whatsapp/language/index.html b/src/packages/plg_mokojoomcross_whatsapp/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/services/index.html b/src/packages/plg_mokojoomcross_whatsapp/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/services/provider.php b/src/packages/plg_mokojoomcross_whatsapp/services/provider.php new file mode 100644 index 0000000..b80b9bf --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Whatsapp\Extension\WhatsappService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new WhatsappService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'whatsapp') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_whatsapp/src/Extension/WhatsappService.php b/src/packages/plg_mokojoomcross_whatsapp/src/Extension/WhatsappService.php new file mode 100644 index 0000000..9265af3 --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/src/Extension/WhatsappService.php @@ -0,0 +1,126 @@ + + * @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\MokoJoomCross\Whatsapp\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * WhatsApp Business service plugin for MokoJoomCross. + * + * Uses the Meta Cloud API (graph.facebook.com) to send messages + * via the WhatsApp Business Platform. + */ +class WhatsappService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'whatsapp'; } + public function getServiceName(): string { return 'WhatsApp Business'; } + public function getMaxLength(): int { return 4096; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + $phoneId = $credentials['phone_number_id'] ?? ''; + $recipient = $credentials['recipient'] ?? ''; + + if (empty($token) || empty($phoneId) || empty($recipient)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token, phone number ID, or recipient.']]; + } + + $apiUrl = 'https://graph.facebook.com/v19.0/' . urlencode($phoneId) . '/messages'; + $payload = json_encode([ + 'messaging_product' => 'whatsapp', + 'to' => $recipient, + 'type' => 'text', + 'text' => ['body' => mb_substr($message, 0, 4096)], + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + $messageId = $data['messages'][0]['id'] ?? ''; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($messageId)) { + return ['success' => true, 'platform_post_id' => $messageId, 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['access_token'] ?? ''; + $phoneId = $credentials['phone_number_id'] ?? ''; + $recipient = $credentials['recipient'] ?? ''; + + if (empty($token) || empty($phoneId)) { + return ['valid' => false, 'message' => 'Access token and phone number ID are required.', 'account_name' => '']; + } + + if (empty($recipient)) { + return ['valid' => false, 'message' => 'Recipient phone number is required.', 'account_name' => '']; + } + + // Verify the phone number ID exists + $ch = curl_init('https://graph.facebook.com/v19.0/' . urlencode($phoneId) . '?fields=display_phone_number,verified_name'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['verified_name'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['verified_name'] . ' (' . ($data['display_phone_number'] ?? '') . ')']; + } + + return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video', 'document']; + } +} diff --git a/src/packages/plg_mokojoomcross_whatsapp/src/Extension/index.html b/src/packages/plg_mokojoomcross_whatsapp/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/src/index.html b/src/packages/plg_mokojoomcross_whatsapp/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_whatsapp/whatsapp.php b/src/packages/plg_mokojoomcross_whatsapp/whatsapp.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/whatsapp.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_whatsapp/whatsapp.xml b/src/packages/plg_mokojoomcross_whatsapp/whatsapp.xml new file mode 100644 index 0000000..1b2e131 --- /dev/null +++ b/src/packages/plg_mokojoomcross_whatsapp/whatsapp.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - WhatsApp Business + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_WHATSAPP_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Whatsapp + + + whatsapp.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_whatsapp.ini + language/en-GB/plg_mokojoomcross_whatsapp.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_mokojoomcross_wordpress/index.html b/src/packages/plg_mokojoomcross_wordpress/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/language/en-GB/index.html b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.ini b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.ini new file mode 100644 index 0000000..40fa758 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WORDPRESS="MokoJoomCross - WordPress" +PLG_MOKOJOOMCROSS_WORDPRESS_DESCRIPTION="Cross-post Joomla articles to WordPress." diff --git a/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.sys.ini b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.sys.ini new file mode 100644 index 0000000..40fa758 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/language/en-GB/plg_mokojoomcross_wordpress.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOJOOMCROSS_WORDPRESS="MokoJoomCross - WordPress" +PLG_MOKOJOOMCROSS_WORDPRESS_DESCRIPTION="Cross-post Joomla articles to WordPress." diff --git a/src/packages/plg_mokojoomcross_wordpress/language/index.html b/src/packages/plg_mokojoomcross_wordpress/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/services/index.html b/src/packages/plg_mokojoomcross_wordpress/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/services/provider.php b/src/packages/plg_mokojoomcross_wordpress/services/provider.php new file mode 100644 index 0000000..48d7766 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/services/provider.php @@ -0,0 +1,38 @@ + + * @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\MokoJoomCross\Wordpress\Extension\WordpressService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new WordpressService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokojoomcross', 'wordpress') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_mokojoomcross_wordpress/src/Extension/WordpressService.php b/src/packages/plg_mokojoomcross_wordpress/src/Extension/WordpressService.php new file mode 100644 index 0000000..1a8eac7 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/src/Extension/WordpressService.php @@ -0,0 +1,131 @@ + + * @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\MokoJoomCross\Wordpress\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * WordPress service plugin for MokoJoomCross. + * + * Uses the WordPress REST API v2 with Application Passwords (Basic Auth). + */ +class WordpressService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices']; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'wordpress'; } + public function getServiceName(): string { return 'WordPress'; } + public function getMaxLength(): int { return 0; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $siteUrl = rtrim($credentials['site_url'] ?? '', '/'); + $username = $credentials['username'] ?? ''; + $appPassword = $credentials['app_password'] ?? ''; + $status = $credentials['default_status'] ?? 'draft'; + + if (empty($siteUrl) || empty($username) || empty($appPassword)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing site URL, username, or application password.']]; + } + + $apiUrl = $siteUrl . '/wp-json/wp/v2/posts'; + $title = mb_substr(strip_tags($message), 0, 200); + + // Append source link if the original article URL is available + $articleUrl = $params['_article_url'] ?? ''; + $content = $message; + + if (!empty($articleUrl)) { + $content .= "\n\n

Originally published at ' + . htmlspecialchars($articleUrl, ENT_QUOTES, 'UTF-8') + . '

'; + } + + $payload = json_encode([ + 'title' => $title, + 'content' => $content, + 'status' => $status, + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode($username . ':' . $appPassword), + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 201 && !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 + { + $siteUrl = rtrim($credentials['site_url'] ?? '', '/'); + $username = $credentials['username'] ?? ''; + $appPassword = $credentials['app_password'] ?? ''; + + if (empty($siteUrl) || empty($username) || empty($appPassword)) { + return ['valid' => false, 'message' => 'Site URL, username, and application password are required.', 'account_name' => '']; + } + + $ch = curl_init($siteUrl . '/wp-json/wp/v2/users/me'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Basic ' . base64_encode($username . ':' . $appPassword)], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['name'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['name']]; + } + + return ['valid' => false, 'message' => $data['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/src/packages/plg_mokojoomcross_wordpress/src/Extension/index.html b/src/packages/plg_mokojoomcross_wordpress/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/src/index.html b/src/packages/plg_mokojoomcross_wordpress/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_mokojoomcross_wordpress/wordpress.php b/src/packages/plg_mokojoomcross_wordpress/wordpress.php new file mode 100644 index 0000000..f74ab97 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/wordpress.php @@ -0,0 +1,11 @@ + + * @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; diff --git a/src/packages/plg_mokojoomcross_wordpress/wordpress.xml b/src/packages/plg_mokojoomcross_wordpress/wordpress.xml new file mode 100644 index 0000000..5213b44 --- /dev/null +++ b/src/packages/plg_mokojoomcross_wordpress/wordpress.xml @@ -0,0 +1,26 @@ + + + MokoJoomCross - WordPress + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOJOOMCROSS_WORDPRESS_DESCRIPTION + + Joomla\Plugin\MokoJoomCross\Wordpress + + + wordpress.php + src + services + language + + + + language/en-GB/plg_mokojoomcross_wordpress.ini + language/en-GB/plg_mokojoomcross_wordpress.sys.ini + + \ No newline at end of file diff --git a/src/packages/plg_system_mokojoomcross/mokojoomcross.xml b/src/packages/plg_system_mokojoomcross/mokojoomcross.xml index d5b1b86..c86d698 100644 --- a/src/packages/plg_system_mokojoomcross/mokojoomcross.xml +++ b/src/packages/plg_system_mokojoomcross/mokojoomcross.xml @@ -1,7 +1,7 @@ System - MokoJoomCross - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php index 665df1b..2afd626 100644 --- a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php +++ b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php @@ -16,151 +16,97 @@ defined('_JEXEC') or die; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Plugin\PluginHelper; -use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; use Joomla\Event\SubscriberInterface; /** - * System plugin that triggers cross-posting when Joomla articles are published. + * System plugin that handles page-load queue processing for MokoJoomCross. * - * Listens for onContentAfterSave events on com_content articles. When an article - * transitions to published state, it dispatches the post to all enabled service - * plugins in the `mokojoomcross` plugin group. + * When queue processing mode is set to "pageload" or "both", this plugin + * processes a small batch of queued cross-posts on each page render, + * throttled to a configurable interval (default 5 minutes). + * + * Content-type event handlers (articles, calendar events, gallery items) + * are handled by their respective plugins, which delegate to the + * CrossPostDispatcher helper for dispatch logic. */ class MokoJoomCross extends CMSPlugin implements SubscriberInterface { public static function getSubscribedEvents(): array { return [ - 'onContentAfterSave' => 'onContentAfterSave', + 'onAfterRender' => 'onAfterRender', ]; } /** - * Triggered after a content item is saved. + * Process queued posts on page load (backend and/or frontend). * - * @param string $context The context (e.g. 'com_content.article') - * @param object $article The article object - * @param bool $isNew Whether this is a new article - * - * @return void + * Only runs if page-load processing is enabled in component config, + * and only once per throttle interval (default 5 minutes). */ - public function onContentAfterSave(string $context, $article, bool $isNew): void + public function onAfterRender(): void { - // Only process Joomla articles - if ($context !== 'com_content.article') { - return; - } - - // Only cross-post when article is published - if ((int) ($article->state ?? 0) !== 1) { - return; - } - - // Check global auto-post setting $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $processingMode = $componentParams->get('queue_processing', 'scheduler'); - if (!$componentParams->get('auto_post_on_publish', 1)) { + if ($processingMode !== 'pageload' && $processingMode !== 'both') { return; } - $this->dispatchCrossPost($article); - } + $app = $this->getApplication(); - /** - * Dispatch article to all enabled service plugins. - * - * @param object $article The article object - * - * @return void - */ - private function dispatchCrossPost(object $article): void - { - $db = Factory::getDbo(); + $pageloadClient = $componentParams->get('pageload_client', 'both'); - // Load all enabled services - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokojoomcross_services')) - ->where($db->quoteName('published') . ' = 1') - ->order($db->quoteName('ordering') . ' ASC'); - - $db->setQuery($query); - $services = $db->loadObjectList(); - - if (empty($services)) { + if ($pageloadClient === 'admin' && !$app->isClient('administrator')) { return; } - // Import service plugins - PluginHelper::importPlugin('mokojoomcross'); - - foreach ($services as $service) { - // Queue the post - $post = (object) [ - 'article_id' => $article->id, - 'service_id' => $service->id, - 'status' => 'queued', - 'message' => $this->renderTemplate($article, $service), - 'created' => Factory::getDate()->toSql(), - 'modified' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokojoomcross_posts', $post); - - // Log the queue action - $log = (object) [ - 'post_id' => $db->insertid(), - 'service_id' => $service->id, - 'level' => 'info', - 'message' => sprintf('Article "%s" queued for %s', $article->title, $service->service_type), - 'context' => json_encode(['article_id' => $article->id]), - 'created' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokojoomcross_logs', $log); + if ($pageloadClient === 'site' && !$app->isClient('site')) { + return; } + + // Throttle: only run once per interval + $throttleSeconds = (int) $componentParams->get('pageload_interval', 300); + $lastRun = (int) $componentParams->get('_pageload_last_run', 0); + + if ((time() - $lastRun) < $throttleSeconds) { + return; + } + + if (!\Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::hasPendingWork()) { + return; + } + + $this->updateLastRunTimestamp(); + + // Small batch to avoid slowing page loads + \Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::processQueue(5); } /** - * Render the message template for a service. - * - * @param object $article The article - * @param object $service The service record - * - * @return string Rendered message + * Store the last page-load run timestamp. */ - private function renderTemplate(object $article, object $service): string + private function updateLastRunTimestamp(): void { $db = Factory::getDbo(); - // Try service-specific template first, fall back to default $query = $db->getQuery(true) - ->select($db->quoteName('template_body')) - ->from($db->quoteName('#__mokojoomcross_templates')) - ->where($db->quoteName('published') . ' = 1') - ->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type) - . ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')') - ->order('CASE WHEN ' . $db->quoteName('service_type') . ' = ' - . $db->quote($service->service_type) . ' THEN 0 ELSE 1 END') - ->setLimit(1); + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); $db->setQuery($query); - $template = $db->loadResult() ?: '{title}\n\n{url}'; + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + $params['_pageload_last_run'] = time(); - // Build article URL - $url = \Joomla\CMS\Uri\Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); - // Replace placeholders - $replacements = [ - '{title}' => $article->title ?? '', - '{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)), - '{url}' => $url, - '{image}' => json_decode($article->images ?? '{}')->image_intro ?? '', - '{category}' => '', - '{author}' => '', - ]; - - return str_replace(array_keys($replacements), array_values($replacements), $template); + $db->setQuery($query); + $db->execute(); } } diff --git a/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.ini b/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.ini new file mode 100644 index 0000000..39f0ed9 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOJOOMCROSS_EVENTS="System - MokoJoomCross Events" +PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION="Cross-posts MokoJoomCalendar events to social media and messaging platforms via MokoJoomCross." diff --git a/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.sys.ini b/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.sys.ini new file mode 100644 index 0000000..39f0ed9 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/language/en-GB/plg_system_mokojoomcross_events.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOJOOMCROSS_EVENTS="System - MokoJoomCross Events" +PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION="Cross-posts MokoJoomCalendar events to social media and messaging platforms via MokoJoomCross." diff --git a/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.php b/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.php new file mode 100644 index 0000000..8ac68ee --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.php @@ -0,0 +1,12 @@ + + * @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; diff --git a/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.xml b/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.xml new file mode 100644 index 0000000..5611768 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/mokojoomcross_events.xml @@ -0,0 +1,26 @@ + + + System - MokoJoomCross Events + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION + + Joomla\Plugin\System\MokoJoomCrossEvents + + + mokojoomcross_events.php + src + services + language + + + + language/en-GB/plg_system_mokojoomcross_events.ini + language/en-GB/plg_system_mokojoomcross_events.sys.ini + + diff --git a/src/packages/plg_system_mokojoomcross_events/services/provider.php b/src/packages/plg_system_mokojoomcross_events/services/provider.php new file mode 100644 index 0000000..75ef607 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/services/provider.php @@ -0,0 +1,38 @@ + + * @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\System\MokoJoomCrossEvents\Extension\MokoJoomCrossEvents; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoJoomCrossEvents( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('system', 'mokojoomcross_events') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_system_mokojoomcross_events/src/Extension/MokoJoomCrossEvents.php b/src/packages/plg_system_mokojoomcross_events/src/Extension/MokoJoomCrossEvents.php new file mode 100644 index 0000000..3af6330 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_events/src/Extension/MokoJoomCrossEvents.php @@ -0,0 +1,88 @@ + + * @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\System\MokoJoomCrossEvents\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher; +use Joomla\Event\SubscriberInterface; + +/** + * System plugin that cross-posts MokoJoomCalendar events when published. + * + * Subscribes to the custom onMokoJoomCalendarEventAfterSave event fired by + * MokoJoomCalendar and maps the calendar event to an article-like payload + * for dispatch through MokoJoomCross services. + */ +class MokoJoomCrossEvents extends CMSPlugin implements SubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + 'onMokoJoomCalendarEventAfterSave' => 'onMokoJoomCalendarEventAfterSave', + ]; + } + + /** + * Cross-post calendar events when published. + */ + public function onMokoJoomCalendarEventAfterSave($event): void + { + // Check com_mokojoomcalendar is installed + if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomcalendar')) { + return; + } + + $item = $event->getArgument('item'); + $isNew = $event->getArgument('isNew'); + + if ((int) ($item->published ?? 0) !== 1) { + return; + } + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$componentParams->get('auto_post_on_publish', 1)) { + return; + } + + if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) { + return; + } + + // Map calendar event to article-like structure + $url = Uri::root() . 'index.php?option=com_mokojoomcalendar&view=event&id=' . $item->id; + + $article = (object) [ + 'id' => (int) $item->id, + 'title' => $item->title ?? '', + 'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)), + 'fulltext' => $item->description ?? '', + 'images' => !empty($item->image) + ? json_encode(['image_intro' => $item->image]) + : '{}', + 'state' => 1, + 'catid' => 0, + 'attribs' => $item->params ?? '{}', + 'publish_up' => $item->start_date ?? $item->created ?? '', + 'created_by' => $item->created_by ?? 0, + '_content_type' => 'mokojoomcalendar', + '_article_id' => (int) $item->id, + '_article_url' => $url, + ]; + + CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomcalendar.event'); + } +} diff --git a/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.ini b/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.ini new file mode 100644 index 0000000..6ec97ad --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOJOOMCROSS_GALLERY="System - MokoJoomCross Gallery" +PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to social media and messaging platforms via MokoJoomCross." diff --git a/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.sys.ini b/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.sys.ini new file mode 100644 index 0000000..6ec97ad --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/language/en-GB/plg_system_mokojoomcross_gallery.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOJOOMCROSS_GALLERY="System - MokoJoomCross Gallery" +PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to social media and messaging platforms via MokoJoomCross." diff --git a/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.php b/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.php new file mode 100644 index 0000000..be4d587 --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.php @@ -0,0 +1,12 @@ + + * @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; diff --git a/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.xml b/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.xml new file mode 100644 index 0000000..34feffd --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/mokojoomcross_gallery.xml @@ -0,0 +1,26 @@ + + + System - MokoJoomCross Gallery + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION + + Joomla\Plugin\System\MokoJoomCrossGallery + + + mokojoomcross_gallery.php + src + services + language + + + + language/en-GB/plg_system_mokojoomcross_gallery.ini + language/en-GB/plg_system_mokojoomcross_gallery.sys.ini + + diff --git a/src/packages/plg_system_mokojoomcross_gallery/services/provider.php b/src/packages/plg_system_mokojoomcross_gallery/services/provider.php new file mode 100644 index 0000000..ef5d80b --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/services/provider.php @@ -0,0 +1,38 @@ + + * @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\System\MokoJoomCrossGallery\Extension\MokoJoomCrossGallery; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoJoomCrossGallery( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('system', 'mokojoomcross_gallery') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_system_mokojoomcross_gallery/src/Extension/MokoJoomCrossGallery.php b/src/packages/plg_system_mokojoomcross_gallery/src/Extension/MokoJoomCrossGallery.php new file mode 100644 index 0000000..ab8e5db --- /dev/null +++ b/src/packages/plg_system_mokojoomcross_gallery/src/Extension/MokoJoomCrossGallery.php @@ -0,0 +1,137 @@ + + * @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\System\MokoJoomCrossGallery\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher; +use Joomla\Event\SubscriberInterface; + +/** + * System plugin that cross-posts MokoJoomGallery galleries and images when published. + * + * Subscribes to custom events fired by MokoJoomGallery and maps gallery/image + * items to article-like payloads for dispatch through MokoJoomCross services. + */ +class MokoJoomCrossGallery extends CMSPlugin implements SubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + 'onMokoJoomGalleryGalleryAfterSave' => 'onMokoJoomGalleryGalleryAfterSave', + 'onMokoJoomGalleryImageAfterSave' => 'onMokoJoomGalleryImageAfterSave', + ]; + } + + /** + * Cross-post galleries when published. + */ + public function onMokoJoomGalleryGalleryAfterSave($event): void + { + if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) { + return; + } + + $item = $event->getArgument('item'); + $isNew = $event->getArgument('isNew'); + + if ((int) ($item->published ?? 0) !== 1) { + return; + } + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$componentParams->get('auto_post_on_publish', 1)) { + return; + } + + if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) { + return; + } + + $url = Uri::root() . 'index.php?option=com_mokojoomgallery&view=category&id=' . $item->id; + + $article = (object) [ + 'id' => (int) $item->id, + 'title' => $item->title ?? '', + 'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)), + 'fulltext' => $item->description ?? '', + 'images' => !empty($item->image) + ? json_encode(['image_intro' => $item->image]) + : '{}', + 'state' => 1, + 'catid' => 0, + 'attribs' => $item->params ?? '{}', + 'publish_up' => $item->created ?? '', + 'created_by' => $item->created_by ?? 0, + '_content_type' => 'mokojoomgallery', + '_article_id' => (int) $item->id, + '_article_url' => $url, + ]; + + CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomgallery.gallery'); + } + + /** + * Cross-post individual images when published. + */ + public function onMokoJoomGalleryImageAfterSave($event): void + { + if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) { + return; + } + + $item = $event->getArgument('item'); + $isNew = $event->getArgument('isNew'); + + if ((int) ($item->published ?? 0) !== 1) { + return; + } + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + + if (!$componentParams->get('auto_post_on_publish', 1)) { + return; + } + + if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) { + return; + } + + $imagePath = $item->original ?? $item->thumbnail ?? ''; + + $url = Uri::root() . 'index.php?option=com_mokojoomgallery&view=category&id=' . ($item->gallery_id ?? 0); + + $article = (object) [ + 'id' => (int) $item->id, + 'title' => $item->title ?? '', + 'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)), + 'fulltext' => $item->description ?? '', + 'images' => $imagePath + ? json_encode(['image_intro' => $imagePath]) + : '{}', + 'state' => 1, + 'catid' => 0, + 'attribs' => '{}', + 'publish_up' => $item->created ?? '', + 'created_by' => $item->created_by ?? 0, + '_content_type' => 'mokojoomgallery', + '_article_id' => (int) $item->id, + '_article_url' => $url, + ]; + + CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomgallery.image'); + } +} diff --git a/src/packages/plg_task_mokojoomcross/index.html b/src/packages/plg_task_mokojoomcross/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/index.html b/src/packages/plg_task_mokojoomcross/language/en-GB/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini new file mode 100644 index 0000000..11a56c5 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini @@ -0,0 +1,9 @@ +; Task - MokoJoomCross Queue Processor Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_TASK_MOKOJOOMCROSS="Task - MokoJoomCross Queue Processor" +PLG_TASK_MOKOJOOMCROSS_DESCRIPTION="Joomla Scheduled Task for processing the MokoJoomCross cross-post queue. Handles queued posts, retries, scheduled posts, and log cleanup." + +PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE_TITLE="MokoJoomCross - Process Queue" +PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE_DESC="Process queued cross-posts, retry failed posts, fire scheduled posts, and clean up old logs." diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini new file mode 100644 index 0000000..734e841 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini @@ -0,0 +1,2 @@ +PLG_TASK_MOKOJOOMCROSS="Task - MokoJoomCross Queue Processor" +PLG_TASK_MOKOJOOMCROSS_DESCRIPTION="Joomla Scheduled Task for processing the MokoJoomCross cross-post queue." diff --git a/src/packages/plg_task_mokojoomcross/language/index.html b/src/packages/plg_task_mokojoomcross/language/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/mokojoomcross.php b/src/packages/plg_task_mokojoomcross/mokojoomcross.php new file mode 100644 index 0000000..ab8e4f7 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/mokojoomcross.php @@ -0,0 +1,12 @@ + + * @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; diff --git a/src/packages/plg_task_mokojoomcross/mokojoomcross.xml b/src/packages/plg_task_mokojoomcross/mokojoomcross.xml new file mode 100644 index 0000000..5d19e9a --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/mokojoomcross.xml @@ -0,0 +1,26 @@ + + + Task - MokoJoomCross Queue Processor + 01.00.06-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_TASK_MOKOJOOMCROSS_DESCRIPTION + + Joomla\Plugin\Task\MokoJoomCross + + + mokojoomcross.php + src + services + language + + + + language/en-GB/plg_task_mokojoomcross.ini + language/en-GB/plg_task_mokojoomcross.sys.ini + + diff --git a/src/packages/plg_task_mokojoomcross/services/index.html b/src/packages/plg_task_mokojoomcross/services/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/services/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/services/provider.php b/src/packages/plg_task_mokojoomcross/services/provider.php new file mode 100644 index 0000000..8bf0746 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/services/provider.php @@ -0,0 +1,38 @@ + + * @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\Task\MokoJoomCross\Extension\MokoJoomCrossTask; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoJoomCrossTask( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('task', 'mokojoomcross') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php b/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php new file mode 100644 index 0000000..4b5f12c --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php @@ -0,0 +1,92 @@ + + * @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\Task\MokoJoomCross\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status as TaskStatus; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; + +/** + * Joomla Scheduled Task plugin for MokoJoomCross queue processing. + * + * Registers with Joomla's Task Scheduler (System → Scheduled Tasks). + * Admin can create a task of type "MokoJoomCross - Process Queue" + * and configure the interval (recommended: every 5 minutes). + * + * This is the PREFERRED processing method. Page-load processing is + * a fallback for environments without cron/scheduler access. + */ +class MokoJoomCrossTask extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] The task type IDs this plugin provides + */ + protected const TASKS_MAP = [ + 'mokojoomcross.process_queue' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE', + 'method' => 'processQueue', + 'form' => '', + ], + ]; + + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * Process the cross-post queue. + * + * @param ExecuteTaskEvent $event The task event + * + * @return int Task status code + */ + private function processQueue(ExecuteTaskEvent $event): int + { + // 1. Process evergreen re-shares (queues new posts for due articles) + $evergreen = QueueProcessor::processEvergreen(); + + if ($evergreen['queued'] > 0) { + $this->logTask(sprintf('MokoJoomCross evergreen: %d re-shares queued', $evergreen['queued'])); + } + + // 2. Process the queue (including any newly queued evergreen posts) + $result = QueueProcessor::processQueue(20); + + $this->logTask(sprintf( + 'MokoJoomCross queue: %d processed, %d succeeded, %d failed, %d skipped', + $result['processed'], + $result['succeeded'], + $result['failed'], + $result['skipped'] + )); + + if ($result['skipped'] === -1) { + $this->logTask('Queue processing skipped — another process holds the lock'); + + return TaskStatus::KNOCKOUT; + } + + return TaskStatus::OK; + } +} diff --git a/src/packages/plg_task_mokojoomcross/src/Extension/index.html b/src/packages/plg_task_mokojoomcross/src/Extension/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/Extension/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/src/index.html b/src/packages/plg_task_mokojoomcross/src/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_webservices_mokojoomcross/mokojoomcross.xml b/src/packages/plg_webservices_mokojoomcross/mokojoomcross.xml index 3326e84..8a60598 100644 --- a/src/packages/plg_webservices_mokojoomcross/mokojoomcross.xml +++ b/src/packages/plg_webservices_mokojoomcross/mokojoomcross.xml @@ -1,7 +1,7 @@ Web Services - MokoJoomCross - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/src/packages/plg_webservices_mokojoomcross/src/Extension/MokoJoomCrossWebServices.php b/src/packages/plg_webservices_mokojoomcross/src/Extension/MokoJoomCrossWebServices.php index 222ced9..73c2e93 100644 --- a/src/packages/plg_webservices_mokojoomcross/src/Extension/MokoJoomCrossWebServices.php +++ b/src/packages/plg_webservices_mokojoomcross/src/Extension/MokoJoomCrossWebServices.php @@ -14,17 +14,17 @@ namespace Joomla\Plugin\WebServices\MokoJoomCross\Extension; defined('_JEXEC') or die; use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Router\ApiRouter; use Joomla\Event\SubscriberInterface; /** * WebServices plugin providing REST API endpoints for MokoJoomCross. * * Endpoints: - * GET /api/index.php/v1/mokojoomcross/posts — List cross-posts - * GET /api/index.php/v1/mokojoomcross/posts/:id — Get single post - * GET /api/index.php/v1/mokojoomcross/services — List services - * POST /api/index.php/v1/mokojoomcross/crosspost — Trigger cross-post for an article + * /api/index.php/v1/mokojoomcross/posts — CRUD cross-posts + * /api/index.php/v1/mokojoomcross/services — CRUD services + * /api/index.php/v1/mokojoomcross/templates — CRUD templates + * /api/index.php/v1/mokojoomcross/logs — Read logs + * /api/index.php/v1/mokojoomcross/dispatch — POST dispatch cross-posts for an article */ class MokoJoomCrossWebServices extends CMSPlugin implements SubscriberInterface { @@ -37,16 +37,16 @@ class MokoJoomCrossWebServices extends CMSPlugin implements SubscriberInterface public function onBeforeApiRoute(&$router): void { - $router->createCRUDRoutes( - 'v1/mokojoomcross/posts', - 'posts', - ['component' => 'com_mokojoomcross'] - ); + $defaults = ['component' => 'com_mokojoomcross']; - $router->createCRUDRoutes( - 'v1/mokojoomcross/services', - 'services', - ['component' => 'com_mokojoomcross'] + $router->createCRUDRoutes('v1/mokojoomcross/posts', 'posts', $defaults); + $router->createCRUDRoutes('v1/mokojoomcross/services', 'services', $defaults); + $router->createCRUDRoutes('v1/mokojoomcross/templates', 'templates', $defaults); + $router->createCRUDRoutes('v1/mokojoomcross/logs', 'logs', $defaults); + + // Action endpoint: dispatch cross-posts for an article (POST only) + $router->addRoute( + new \Joomla\Router\Route(['POST'], 'v1/mokojoomcross/dispatch', 'dispatch.dispatch', [], $defaults) ); } } diff --git a/src/pkg_mokojoomcross.xml b/src/pkg_mokojoomcross.xml index 5e545a9..294712d 100644 --- a/src/pkg_mokojoomcross.xml +++ b/src/pkg_mokojoomcross.xml @@ -2,7 +2,7 @@ MokoJoomCross mokojoomcross - 01.00.00-dev + 01.00.06-dev 2026-05-28 Moko Consulting hello@mokoconsulting.tech @@ -19,6 +19,7 @@ plg_system_mokojoomcross.zip plg_content_mokojoomcross.zip plg_webservices_mokojoomcross.zip + plg_task_mokojoomcross.zip plg_mokojoomcross_facebook.zip @@ -30,6 +31,35 @@ plg_mokojoomcross_telegram.zip plg_mokojoomcross_discord.zip plg_mokojoomcross_slack.zip + + + plg_mokojoomcross_webhook.zip + plg_mokojoomcross_teams.zip + plg_mokojoomcross_threads.zip + plg_mokojoomcross_googlebusiness.zip + plg_mokojoomcross_whatsapp.zip + plg_mokojoomcross_googlechat.zip + plg_mokojoomcross_medium.zip + plg_mokojoomcross_pinterest.zip + plg_mokojoomcross_reddit.zip + plg_mokojoomcross_sendgrid.zip + plg_mokojoomcross_brevo.zip + plg_mokojoomcross_wordpress.zip + plg_mokojoomcross_ntfy.zip + plg_mokojoomcross_tumblr.zip + plg_mokojoomcross_convertkit.zip + plg_mokojoomcross_nostr.zip + plg_mokojoomcross_activitypub.zip + plg_mokojoomcross_devto.zip + plg_mokojoomcross_ghost.zip + plg_mokojoomcross_hashnode.zip + plg_mokojoomcross_blogger.zip + plg_mokojoomcross_matrix.zip + plg_mokojoomcross_rssfeed.zip + plg_mokojoomcross_constantcontact.zip + plg_mokojoomcross_tiktok.zip + plg_mokojoomcross_mokojoomcalendar.zip + plg_mokojoomcross_mokojoomgallery.zip @@ -37,6 +67,8 @@ - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/main/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/updates.xml + + true diff --git a/src/script.php b/src/script.php index 6e3d090..cb7d5e9 100644 --- a/src/script.php +++ b/src/script.php @@ -63,6 +63,7 @@ class Pkg_MokoJoomCrossInstallerScript ['system', 'mokojoomcross'], ['content', 'mokojoomcross'], ['webservices', 'mokojoomcross'], + ['task', 'mokojoomcross'], ]; foreach ($corePlugins as [$folder, $element]) { diff --git a/updates.xml b/updates.xml deleted file mode 100644 index 9cc1244..0000000 --- a/updates.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - MokoJoomCross - MokoJoomCross development build. - pkg_mokojoomcross - package - site - 01.00.00-dev - 2026-05-28 - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/releases/tag/development - - https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/releases/download/development/pkg_mokojoomcross-01.00.00-dev.zip - - development - Moko Consulting - https://mokoconsulting.tech - - - diff --git a/wiki/Developer-Guide.md b/wiki/Developer-Guide.md new file mode 100644 index 0000000..0e80764 --- /dev/null +++ b/wiki/Developer-Guide.md @@ -0,0 +1,337 @@ +# Developer Guide + +This guide covers building new service plugins for MokoJoomCross — from directory structure through testing. + +## Plugin Directory Structure + +Each service plugin lives in its own package under `src/packages/`: + +``` +plg_mokojoomcross_myservice/ +├── myservice.xml ← Joomla manifest (type="plugin", group="mokojoomcross") +├── myservice.php ← Legacy loader stub (empty, required by Joomla) +├── services/ +│ └── provider.php ← DI container: registers the Extension class +└── src/ + └── Extension/ + └── MyServiceService.php ← Main class: implements the interface +``` + +## MokoJoomCrossServiceInterface + +Every service plugin **must** implement `MokoJoomCrossServiceInterface`. The interface defines 5 methods: + +```php +namespace Joomla\Component\MokoJoomCross\Administrator\Service; + +interface MokoJoomCrossServiceInterface +{ + /** + * Unique identifier matching the service_type in service.xml. + * Must match exactly (e.g. 'mastodon', 'telegram'). + */ + public function getServiceType(): string; + + /** + * Human-readable display name (e.g. 'Mastodon', 'Telegram'). + */ + public function getServiceName(): string; + + /** + * Post content to the platform. + * + * @param string $message Rendered message text (already template-processed) + * @param array $media Array of media file paths (images) + * @param array $credentials Decrypted credential key-value pairs from the service record + * @param array $params Plugin params + service params merged + * @return array ['success' => bool, 'platform_post_id' => string, 'response' => array] + */ + public function publish(string $message, array $media, array $credentials, array $params): array; + + /** + * Test whether the stored credentials are valid. + * + * @param array $credentials Decrypted credential key-value pairs + * @return array ['valid' => bool, 'message' => string, 'account_name' => string] + */ + public function validateCredentials(array $credentials): array; + + /** + * Platform character limit (0 = unlimited). + */ + public function getMaxLength(): int; + + /** + * Whether this service supports image/media attachments. + */ + public function supportsMedia(): bool; +} +``` + +## Step-by-Step: Creating a New Service Plugin + +### 1. Create the manifest (`myservice.xml`) + +```xml + + + plg_mokojoomcross_myservice + Moko Consulting + 1.0.0 + MyService integration for MokoJoomCross + Joomla\Plugin\MokoJoomCross\MyService + + myservice.php + services + src + + + + +
+ +
+
+
+
+``` + +### 2. Create the legacy stub (`myservice.php`) + +```php +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new MyServiceService($dispatcher, (array) PluginHelper::getPlugin('mokojoomcross', 'myservice')); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; +``` + +### 4. Create the Extension class + +```php + 'onMokoJoomCrossGetServices', + ]; + } + + public function onMokoJoomCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string + { + return 'myservice'; + } + + public function getServiceName(): string + { + return 'My Service'; + } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + // Your API integration here + // $credentials contains the decrypted values from service.xml fields + // e.g. $credentials['api_key'], $credentials['webhook_url'] + + return [ + 'success' => true, + 'platform_post_id' => 'abc123', + 'response' => ['status' => 'ok'], + ]; + } + + public function validateCredentials(array $credentials): array + { + // Test the credentials against the platform API + return [ + 'valid' => true, + 'message' => 'Connected', + 'account_name' => 'MyAccount', + ]; + } + + public function getMaxLength(): int + { + return 0; // 0 = no limit + } + + public function supportsMedia(): bool + { + return false; + } +} +``` + +### 5. Add credential fields to `service.xml` + +In `src/packages/com_mokojoomcross/forms/service.xml`, add your fields with `showon`: + +```xml + + +``` + +### 6. Add language strings to `com_mokojoomcross.ini` + +```ini +COM_MOKOJOOMCROSS_CRED_MYSERVICE_KEY="API Key" +``` + +### 7. Add to the service_type dropdown (if not already listed) + +In the `` list in `service.xml`, add: + +```xml + +``` + +## How `showon` Credential Fields Work + +Joomla's `showon` attribute controls field visibility client-side via JavaScript: + +| Pattern | Meaning | +|---------|---------| +| `showon="service_type:telegram"` | Show when service type is Telegram | +| `showon="service_type:telegram[AND]cred_mode:custom"` | Show when Telegram AND custom mode | +| `showon="service_type:webhook[AND]cred_webhook_auth_type:bearer,basic"` | Show when webhook AND auth is bearer or basic | + +Fields are hidden/shown without page reloads. The form data for hidden fields is still submitted but ignored by the component. + +## Dispatch Pipeline + +The cross-posting flow works like this: + +1. **Article published** → System plugin (`plg_system_mokojoomcross`) catches `onContentAfterSave` +2. **Queue creation** → For each enabled service, a `#__mokojoomcross_posts` row is created with status `queued` +3. **Queue processing** → Either the Scheduled Task or page-load fallback picks up queued posts +4. **Service dispatch** → `QueueProcessor` fires `onMokoJoomCrossGetServices` event in the `mokojoomcross` plugin group +5. **Plugin response** → Each registered service plugin adds itself to the `$services` array +6. **Matching** → The processor finds the plugin whose `getServiceType()` matches the service record's `service_type` +7. **Publishing** → `publish()` is called with the rendered message, media paths, decrypted credentials, and params +8. **Result** → The post record is updated with `posted`/`failed` status and the platform response + +## Default Bot Mode + +Some services (Telegram, Discord, Slack, Teams, Facebook, Threads) support a **default mode** where pre-configured MokoWaaS credentials are used. This is controlled by: + +1. The `cred_mode` field in `service.xml` (shown for services listed in its `showon`) +2. Plugin-level params in the plugin manifest (`` section) that store default tokens +3. The service plugin's `publish()` method checks `$credentials['mode']`: + - `'default'` → use plugin params (`$this->params->get('default_token')`) + - `'custom'` → use the per-service credentials from `$credentials` + +## OAuth Integration + +For services requiring OAuth (Facebook, LinkedIn, Twitter, Pinterest, etc.): + +1. **OAuthHelper** (`src/packages/com_mokojoomcross/src/Helper/OAuthHelper.php`) handles: + - Authorization URL generation with state parameter + - Code-to-token exchange + - Token storage back to the service record's credentials + +2. **OauthController** provides two endpoints: + - `task=oauth.authorize` → redirects to the platform's auth page + - `task=oauth.callback` → handles the redirect, exchanges code for token + +3. Plugin params store the OAuth Client ID and Secret (set in Extensions → Plugins) + +4. In `edit.php`, services listed in `$oauthServices` get a "Connect to {Service}" button + +## Testing Your Plugin + +1. **Syntax check**: `php -l src/packages/plg_mokojoomcross_myservice/src/Extension/MyServiceService.php` +2. **Install**: Include the plugin in `pkg_mokojoomcross.xml` or install the plugin ZIP standalone +3. **Enable**: Extensions → Plugins → search "mokojoomcross myservice" → Enable +4. **Add service**: Components → MokoJoomCross → Services → New → select your service type +5. **Verify fields**: Confirm your credential fields appear when your service type is selected +6. **Test post**: Publish an article and check the Post Queue for results + +## Example: Building a "Fediverse" Service + +Imagine building a service for a Mastodon-compatible platform: + +```php +public function publish(string $message, array $media, array $credentials, array $params): array +{ + $instanceUrl = rtrim($credentials['instance_url'] ?? '', '/'); + $token = $credentials['access_token'] ?? ''; + + $ch = curl_init($instanceUrl . '/api/v1/statuses'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['status' => $message]), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $token, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 200 && !empty($data['id'])) { + return ['success' => true, 'platform_post_id' => $data['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; +} +``` + +This pattern — curl to API, check response code, return structured result — is the same for every service plugin. The only differences are the API endpoint, authentication method, and payload format. diff --git a/wiki/Home.md b/wiki/Home.md index fa66416..06bc764 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -28,6 +28,7 @@ - [[REST API]] - [[Migration from Perfect Publisher Pro]] - [[Adding Custom Services]] +- [[Developer Guide]] - [[Troubleshooting]] ## Architecture diff --git a/wiki/Message-Templates.md b/wiki/Message-Templates.md new file mode 100644 index 0000000..5d96a37 --- /dev/null +++ b/wiki/Message-Templates.md @@ -0,0 +1,77 @@ +# Message Templates + +MokoJoomCross uses message templates to format the content sent to each platform. Templates support placeholders that are replaced with article data at post time. + +## Managing Templates + +Navigate to **Components → MokoJoomCross → Templates** to create and edit templates. + +## Template Priority + +When cross-posting, the system looks for templates in this order: +1. **Platform-specific template** — matches the service type exactly (e.g., "twitter") +2. **Default template** — fallback used when no platform-specific template exists + +## Available Placeholders + +| Placeholder | Description | Example | +|-------------|-------------|---------| +| `{title}` | Article title | "New Product Launch" | +| `{url}` | Full article URL | "https://example.com/article/123" | +| `{introtext}` | Intro text (280 chars, HTML stripped) | "We're excited to announce..." | +| `{fulltext}` | Full text (500 chars, HTML stripped) | Extended content | +| `{image}` | Intro image full URL | "https://example.com/images/photo.jpg" | +| `{category}` | Article category name | "News" | +| `{author}` | Author display name | "John Smith" | +| `{date}` | Publish date (YYYY-MM-DD) | "2026-05-28" | + +## Example Templates + +### Default (all platforms) +``` +{title} + +{introtext} + +{url} +``` + +### Twitter / X (280 char limit) +``` +{title} + +{url} +``` + +### Mastodon (with hashtags) +``` +{title} + +{introtext} + +{url} + +#Joomla #{category} +``` + +### Mailchimp (HTML email) +```html +

{title}

+

{introtext}

+

Read the full article

+``` + +### Telegram (HTML format) +```html +{title} + +{introtext} + +Read more +``` + +## Per-Article Override + +In the article editor, the **Cross-Posting** tab lets you: +- Skip cross-posting entirely for a specific article +- Select which services to post to (instead of all enabled services) diff --git a/wiki/REST-API.md b/wiki/REST-API.md new file mode 100644 index 0000000..f515501 --- /dev/null +++ b/wiki/REST-API.md @@ -0,0 +1,57 @@ +# REST API + +MokoJoomCross includes a WebServices plugin that provides REST API endpoints via Joomla's API application. + +## Authentication + +All endpoints require a Joomla API token. Generate one in **Users → Manage → [User] → API Tokens**. + +Include the token in the `Authorization` header: + +``` +Authorization: Bearer YOUR_API_TOKEN +``` + +## Base URL + +``` +https://yoursite.com/api/index.php/v1/mokojoomcross/ +``` + +## Endpoints + +### Posts + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/mokojoomcross/posts` | List all cross-posts | +| GET | `/v1/mokojoomcross/posts/:id` | Get single post details | +| POST | `/v1/mokojoomcross/posts` | Create a cross-post entry | +| DELETE | `/v1/mokojoomcross/posts/:id` | Delete a post | + +### Services + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/mokojoomcross/services` | List connected services | +| GET | `/v1/mokojoomcross/services/:id` | Get service details | + +## Example + +```bash +# List all posts +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://yoursite.com/api/index.php/v1/mokojoomcross/posts + +# List services +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://yoursite.com/api/index.php/v1/mokojoomcross/services +``` + +## Filtering + +Posts support query parameters: +- `filter[status]=posted` — Filter by status (queued, posting, posted, failed, scheduled) +- `filter[service_id]=5` — Filter by service +- `page[limit]=20` — Pagination limit +- `page[offset]=0` — Pagination offset diff --git a/wiki/Services.md b/wiki/Services.md new file mode 100644 index 0000000..b3e0c20 --- /dev/null +++ b/wiki/Services.md @@ -0,0 +1,60 @@ +# Services + +MokoJoomCross supports 9 platforms. Each is a separate plugin that can be enabled or disabled independently. + +## Social Media + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Facebook** | plg_mokojoomcross_facebook | No limit | Yes | Yes | +| **X / Twitter** | plg_mokojoomcross_twitter | 280 | Yes | No | +| **LinkedIn** | plg_mokojoomcross_linkedin | 3,000 | Yes | No | +| **Mastodon** | plg_mokojoomcross_mastodon | 500 | Yes | No | +| **Bluesky** | plg_mokojoomcross_bluesky | 300 | Yes | No | + +## Email Marketing + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Mailchimp** | plg_mokojoomcross_mailchimp | No limit | Yes | No | + +## Chat / Messaging + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Telegram** | plg_mokojoomcross_telegram | 4,096 | Yes | Yes (@MokoWaaSBot) | +| **Discord** | plg_mokojoomcross_discord | 2,000 | Yes | Yes (webhook) | +| **Slack** | plg_mokojoomcross_slack | 40,000 | Yes | Yes (webhook) | + +## Default vs Custom Mode + +Services with "Default Bot" support offer two operating modes: + +- **Default Mode**: Uses a pre-configured bot/app token managed by Moko. The admin only needs to provide a destination (chat ID, page ID, etc.). The API key is stored in the plugin's configuration and never visible in the service record. + +- **Custom Mode**: The admin provides their own API keys, tokens, or webhook URLs. Full control, but requires setting up your own app/bot on the platform. + +Configure default tokens in **Extensions → Plugins → MokoJoomCross - [Platform]**. + +## Adding a Service + +1. Go to **Components → MokoJoomCross → Services** +2. Click **New** +3. Select the service type +4. Enter a title and choose credentials mode +5. For **Default mode**: enter only the destination (chat ID, channel, etc.) +6. For **Custom mode**: enter your full API credentials as JSON +7. Save and enable + +## Credentials Format + +Each service expects specific JSON fields. See the individual service pages: +- [[Telegram]] — bot_token, chat_id +- [[Facebook]] — page_access_token, page_id +- [[Discord]] — webhook_url +- [[Slack]] — webhook_url +- [[LinkedIn]] — access_token, organization_id +- [[Mastodon]] — instance_url, access_token +- [[Bluesky]] — handle, app_password +- [[Mailchimp]] — api_key, list_id +- [[Twitter (X)]] — bearer_token, api_key, api_secret diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md new file mode 100644 index 0000000..f0f13f0 --- /dev/null +++ b/wiki/Troubleshooting.md @@ -0,0 +1,48 @@ +# Troubleshooting + +## Posts Stuck in "Queued" Status + +**Cause**: The queue processor isn't running. + +**Fix**: +1. Check **Components → MokoJoomCross → Options → Queue Processing** — ensure it's set to "Scheduler" or "Both" +2. If using Scheduler: verify a task exists in **System → Scheduled Tasks** of type "MokoJoomCross - Process Queue" +3. If using Page-load: ensure the system plugin is enabled and check the throttle interval + +## Posts Failing + +**Cause**: Invalid credentials or platform API changes. + +**Fix**: +1. Check the error message in **Components → MokoJoomCross → Post Queue** (hover over the red "Failed" badge) +2. Check **Activity Logs** for detailed error messages +3. Go to **Services** and verify credentials +4. For services using Default mode, check the plugin params in **Extensions → Plugins** + +## "No service plugin found" Warning + +**Cause**: The service plugin for that platform is disabled. + +**Fix**: Go to **Extensions → Plugins**, search for "MokoJoomCross", and enable the relevant service plugin. + +## Cross-posting Not Triggering on Publish + +**Cause**: Auto-post is disabled or system plugin is inactive. + +**Fix**: +1. Check **Components → MokoJoomCross → Options** — "Auto-post on Publish" should be "Yes" +2. Verify **Extensions → Plugins → System - MokoJoomCross** is enabled +3. Check that at least one service is configured and enabled + +## Duplicate Posts + +MokoJoomCross has a built-in duplicate guard. If you're seeing duplicates: +1. Check if the article was saved multiple times in quick succession +2. Check if both page-load and scheduler are running (shouldn't cause duplicates, but verify) +3. Review the **Activity Logs** for the article in question + +## OAuth Connection Failing + +1. Verify the OAuth Client ID and Secret are correct in the plugin params +2. Check that the redirect URI matches: `https://yoursite.com/administrator/index.php?option=com_mokojoomcross&task=oauth.callback` +3. Ensure your Joomla site uses HTTPS (required by most OAuth providers)