Compare commits

...

70 Commits

Author SHA1 Message Date
gitea-actions[bot] 40e540461e chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 05:29:01 +00:00
Jonathan Miller 8dd6fdd926 fix: critical and high severity audit fixes
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 10s
C-1: CSRF nonce on OAuth authorize/callback flow
C-2: POST method enforcement on REST dispatch endpoint
C-5: Service credential fields now saved from form to JSON column
     (collect cred_* fields, strip prefix, JSON encode on save;
      expand back on load for editing)
H-1: Joomla 5 event ArrayAccess pattern for service plugin collection
     (reads from Event indices instead of broken by-reference)
H-4: ServiceTable::check() with alias generation, required validation
H-9: WebhookService credential keys match form XML field names,
     Bearer/Basic auth headers implemented correctly
M-4: XSS fix — escape $extraClass in ServiceIconHelper::renderIcon()
M-5: Article history HTML injection via setFieldAttribute() instead
     of double-escaped XML description attribute

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 00:28:36 -05:00
gitea-actions[bot] 353c037907 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 05:13:42 +00:00
gitea-actions[bot] fcb332ea00 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 05:13:40 +00:00
Jonathan Miller 20ee39f54b feat: 5 final features — category routing, char counter, service icons, drill-down analytics, article history
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
1. Category routing rules — new DB table #__mokojoomcross_category_rules
   maps Joomla categories to specific services (whitelist). Integrated
   into CrossPostDispatcher before per-article filters.
2. Character counter — live JS counter in template editor shows
   remaining chars per platform with color coding (green/yellow/red)
3. Service type icons — ServiceIconHelper maps 34 types to Bootstrap
   icons, used in services list, posts list, and dashboard
4. Per-service analytics drill-down — ServiceStats view with stats
   cards, daily trend chart, recent posts, top articles. Dashboard
   service rows are now clickable links.
5. Article editor cross-post history — read-only panel in the
   Cross-Posting fieldset showing last 10 post results with status
   badges, service names, and timestamps

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 00:13:30 -05:00
gitea-actions[bot] 57f34f0fc7 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:56:02 +00:00
gitea-actions[bot] 58a9641b94 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:56:01 +00:00
Jonathan Miller 16c068b4b0 feat: getSupportedMediaTypes() on all 36 service plugins
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
Add media capability reporting to MokoJoomCrossServiceInterface.
Each plugin now returns its supported media types:
- image, video, gif, document (per platform capability)
- Empty array for text-only services (Nostr, Ntfy, ConvertKit)

Enables the dispatcher to skip media attachments for text-only
services and choose appropriate media types per platform.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:55:51 -05:00
gitea-actions[bot] de869c2b5d chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:52:43 +00:00
gitea-actions[bot] b8cd0253e1 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:52:42 +00:00
Jonathan Miller c3899b65d3 feat: bulk re-queue, purge posted, CSV export, package manifest update
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 8s
- PostsController: retryFailed(), purgePosted(), exportCsv() tasks
- Posts HtmlView: Retry Failed, Purge Posted, Export CSV toolbar buttons
- pkg_mokojoomcross.xml: added new sub-extension entries

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:52:30 -05:00
gitea-actions[bot] 7777ffca32 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:48:32 +00:00
gitea-actions[bot] 90340dd499 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:48:31 +00:00
Jonathan Miller 7747fef50e refactor: split content-type dispatch into pluggable source plugins
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 8s
Extract dispatch logic from monolithic system plugin into a shared
CrossPostDispatcher helper. Each content type now has its own plugin:

- plg_content_mokojoomcross — articles (onContentAfterSave/ChangeState)
- plg_system_mokojoomcross_events — MokoJoomCalendar events
- plg_system_mokojoomcross_gallery — MokoJoomGallery galleries/images
- plg_mokojoomcross_mokojoomcalendar — calendar service enrichment
- plg_mokojoomcross_mokojoomgallery — gallery service enrichment

System plugin stripped to page-load queue processing only.

Also fixes: onContentBeforeDisplay Joomla 5/6 BeforeDisplayEvent
compatibility (was crashing with wrong argument type).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:48:18 -05:00
gitea-actions[bot] 3586ce7661 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:33:54 +00:00
gitea-actions[bot] 83dc2fa013 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:33:52 +00:00
Jonathan Miller 9544f4f0bb feat: 7 medium-effort enhancements
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 10s
1. Image attachment pipeline — article intro image passed to publish()
   via $media array (system plugin + QueueProcessor)
2. Custom fields as placeholders — {field:xxx} resolves Joomla custom
   fields in templates
3. Lifecycle events — onMokoJoomCrossBeforePost (cancellable),
   AfterPost, PostFailed for third-party hooks
4. Token auto-refresh — OAuthHelper::refreshTokenIfNeeded() checks
   token_expires and refreshes via refresh_token before each publish
5. DB lock race fix — MySQL GET_LOCK() replaces read-then-write pattern
6. WordPress canonical URL — appends source link to cross-posted content
7. REST API dispatch — POST /api/v1/mokojoomcross/dispatch triggers
   cross-posting programmatically with article_id + optional service_ids

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:33:42 -05:00
gitea-actions[bot] 288dbb2240 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 00:14:13 +00:00
gitea-actions[bot] 37b32a56b3 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 00:14:12 +00:00
Jonathan Miller 3b501719ff feat: 10 quick-win enhancements
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
1. Test Connection button — AJAX validation on service edit sidebar
2. Bulk re-queue failed + purge posted — toolbar buttons on Posts list
3. Exponential backoff — retry_delay * 2^retry_count replaces fixed delay
4. Queue depth warning — dashboard alert when queued > 50
5. First-publish-only toggle — skip cross-posting on article re-saves
6. Dashboard trend chart — Chart.js line chart for daily posted/failed
7. Hashtag injection — {tags} and {hashtags} template placeholders
8. Posts list filters — service dropdown + search by article/message
9. CSV export — download filtered post history as spreadsheet
10. Dashboard date range — 7d/30d/90d/all filter on analytics

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 19:14:03 -05:00
gitea-actions[bot] 5325293db4 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-28 23:56:27 +00:00
gitea-actions[bot] 06b27095ab chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-28 23:56:26 +00:00
Jonathan Miller 362ce47e71 feat: post edit form, manual post creator, and scheduled posts
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
Complete the Post CRUD that was previously stub-only:
- PostModel (AdminModel) for loading/saving individual posts
- Post HtmlView with toolbar (apply, save, cancel, dashboard)
- post.xml form with article selector, service selector, message
  textarea, status dropdown, and scheduled_at calendar picker
- Post edit template with results sidebar and re-queue button
- Posts list: New button in toolbar, clickable article titles,
  scheduled_at display with clock icon
- 20 new language strings for the post edit UI

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 18:56:13 -05:00
gitea-actions[bot] 911aac785b chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-28 23:47:05 +00:00
gitea-actions[bot] 865a877f99 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-28 23:47:04 +00:00
Jonathan Miller 1acb7f3778 feat: evergreen content re-sharing
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
Articles can be marked as "evergreen" in the Cross-Posting fieldset,
with a configurable re-share interval (default 30 days). The queue
processor checks for due articles and re-queues them automatically,
bypassing the duplicate guard for articles whose last successful post
exceeds the interval.

- Per-article: evergreen toggle + interval (days) in article editor
- Global config: enable/disable, default interval, max per run
- QueueProcessor::processEvergreen() finds and re-queues due articles
- Task plugin calls processEvergreen() before processQueue()

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 18:46:56 -05:00
gitea-actions[bot] f8d1934d14 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-28 22:40:18 +00:00
gitea-actions[bot] 435d4e8392 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-28 22:40:15 +00:00
Jonathan Miller dc53ef48d1 feat: per-service help links to KB articles on mokoconsulting.tech
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Update Server / Update Server (push) Successful in 16s
Service edit sidebar now shows a contextual "Setup Guide" button when
a service type is selected. Links to the matching KB article on the
live site (e.g., /kb/mokojoomcross/service-twitter-mokojoomcross).
All 34 service types mapped.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 17:40:03 -05:00
gitea-actions[bot] 1f76d7d2e9 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-28 22:05:04 +00:00
gitea-actions[bot] 7262506d8e chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-28 22:05:02 +00:00
Jonathan Miller 12074b71c3 fix: rewrite 13 broken service plugins with correct API implementations
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 10s
All 13 plugins had copy-paste stub code with literal placeholder URLs
(e.g. '{site_url}/api/endpoint') that were never substituted with
actual credential values. Each plugin now has correct:
- URL construction from credentials
- Auth method (Basic Auth for WP, JWT for Ghost, GraphQL for Hashnode)
- API payload format per platform spec
- Credential validation with live API checks

Fixed: ActivityPub, Blogger, Ghost, Google Business, Hashnode, Matrix,
Medium, Nostr (stub), RSS Feed, Threads, Tumblr, WhatsApp, WordPress.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 17:04:52 -05:00
gitea-actions[bot] 949f58506c chore: update development channel 01.00.13-dev [skip ci] 2026-05-28 21:51:14 +00:00
gitea-actions[bot] e23ddf9344 chore(version): auto-bump 01.00.13-dev [skip ci] 2026-05-28 21:51:12 +00:00
Jonathan Miller 5c86bdc24c fix: add missing Toolbar and Route imports in 5 admin views
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Update Server / Update Server (push) Successful in 12s
Dashboard link buttons in Logs, Posts, Services, Template, and
Templates views used Toolbar::getInstance() and Route::_ without
importing the classes — causing fatal errors on page load.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 16:50:58 -05:00
jmiller e25c6a9885 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:51:51 +00:00
jmiller 39d9d6fe1d chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:47:01 +00:00
gitea-actions[bot] b57910f63b chore: update development channel 01.00.12-dev [skip ci] 2026-05-28 20:38:48 +00:00
gitea-actions[bot] 77cac8c8c3 chore(version): auto-bump 01.00.12-dev [skip ci] 2026-05-28 20:38:46 +00:00
Jonathan Miller 430d6a79f4 feat: complete service credential fields + fix Twitter OAuth 1.0a
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 12s
Fix Twitter posting by replacing Bearer token (app-only, read-only)
with OAuth 1.0a HMAC-SHA1 signing using all 4 keys. Add credential
fields for 19 previously missing services and optional fields for
7 existing services. Add Developer Guide wiki page.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -05:00
jmiller bf835e9063 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:29:05 +00:00
jmiller 044bcdae76 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:09:56 +00:00
gitea-actions[bot] 226fb84dd4 chore: update development channel 01.00.11-dev [skip ci] 2026-05-28 20:07:05 +00:00
jmiller b2e2630d44 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:06:52 +00:00
gitea-actions[bot] f0c79b3f32 chore: update development channel 01.00.11-dev [skip ci] 2026-05-28 20:06:51 +00:00
gitea-actions[bot] fe3ac2f54a chore(version): auto-bump 01.00.11-dev [skip ci] 2026-05-28 20:06:48 +00:00
Jonathan Miller dabce55cc7 feat: user-friendly service form + Dashboard toolbar button
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Update Server / Update Server (push) Successful in 15s
Replaced raw JSON credentials textarea with individual form fields
per service type using Joomla showon directives. Each service now
has labeled inputs with help descriptions:

- Telegram: Chat ID + Bot Token (custom mode only)
- Discord/Slack/Teams/Google Chat: Webhook URL
- Facebook: Page ID + Page Access Token
- Twitter: Bearer Token + API Key + API Secret
- LinkedIn: Access Token + Organization ID
- Mastodon: Instance URL + Access Token
- Bluesky: Handle + App Password
- WhatsApp: Access Token + Phone Number ID + Recipient
- Mailchimp/SendGrid: API Key + List ID
- WordPress: Site URL + Username + App Password
- Webhook: URL + HTTP Method
- Matrix: Homeserver + Token + Room ID
- Ntfy: Server + Topic + Token
- Reddit: Client ID + Secret + Username + Subreddit
- Medium/Dev.to/Ghost/Blogger: API keys/tokens

Default/Custom mode selector for services with MokoWaaS bot support.

Authorize button for OAuth services (Facebook, LinkedIn, Twitter,
Threads) — visible after first save.

Dashboard button added to toolbar on ALL views (Services, Posts,
Templates, Logs, Service edit, Template edit).

Help panel sidebar in service edit with setup steps.
90+ new language strings for credential fields and help text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 15:06:32 -05:00
jmiller 6d477d9f23 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:03:31 +00:00
gitea-actions[bot] 5d3da335f3 chore: update development channel 01.00.10-dev [skip ci] 2026-05-28 19:53:56 +00:00
gitea-actions[bot] c463950990 chore: update development channel 01.00.10-dev [skip ci] 2026-05-28 19:53:46 +00:00
gitea-actions[bot] f90e0954f0 chore(version): auto-bump 01.00.10-dev [skip ci] 2026-05-28 19:53:44 +00:00
Jonathan Miller 7a57b001e3 fix: add Service edit view + more default templates
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
- Added View/Service/HtmlView.php and tmpl/service/edit.php for the
  service edit form (was causing 404 "View not found" error)
- Added credential hint panel in service edit sidebar
- Added 16 more default templates (telegram, discord, slack, facebook,
  linkedin, bluesky, threads, teams, medium, wordpress, webhook,
  sendgrid, brevo, ntfy, reddit, pinterest) — total 20 default templates
- Added credential hint language strings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 14:53:31 -05:00
Moko Consulting 6cba84bde5 fix(workflows): rename remaining old secrets in repo-specific workflows [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-28 14:48:22 -05:00
gitea-actions[bot] 5b8aa86357 chore: update development channel 01.00.09-dev [skip ci] 2026-05-28 19:42:06 +00:00
gitea-actions[bot] 9982a4bffd chore: update development channel 01.00.09-dev [skip ci] 2026-05-28 19:41:41 +00:00
gitea-actions[bot] cf7fd55eca chore(version): auto-bump 01.00.09-dev [skip ci] 2026-05-28 19:41:40 +00:00
Jonathan Miller 994cbf2701 fix: filterForm null error + PrepareFormEvent compat + API routes
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
Fixes three issues found during dev site testing:

1. All 4 list views (Services, Posts, Logs, Templates) missing
   filterForm and activeFilters properties. Joomla searchtools
   layout calls getGroup() on null filterForm. Added get('FilterForm')
   and get('ActiveFilters') to all list HtmlView classes.

2. Content plugin onContentPrepareForm typed as Form but Joomla 5/6
   passes PrepareFormEvent. Now accepts both: extracts Form from
   PrepareFormEvent when available, falls back to legacy Form type.

3. WebServices API routes expanded: added templates and logs CRUD
   endpoints alongside posts and services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 14:41:24 -05:00
Moko Consulting 4e0c776c70 fix(workflows): GITHUB_TOKEN→GH_MIRROR_TOKEN (reserved name) [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-28 14:37:15 -05:00
gitea-actions[bot] 207ad9c2c6 chore: update development channel 01.00.08-dev [skip ci] 2026-05-28 19:33:40 +00:00
gitea-actions[bot] 973f83dc32 chore: update development channel 01.00.08-dev [skip ci] 2026-05-28 19:33:23 +00:00
gitea-actions[bot] 55596a1024 chore(version): auto-bump 01.00.08-dev [skip ci] 2026-05-28 19:33:21 +00:00
Jonathan Miller 73d8425130 fix: remove DEFAULT '' from TEXT column error_message
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 10s
MySQL strict mode does not allow default values on TEXT/BLOB columns.
Removes DEFAULT '' from error_message in #__mokojoomcross_posts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 14:33:09 -05:00
Moko Consulting 50152524b9 chore(workflows): sync all universal workflows from moko-platform [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-28 14:25:15 -05:00
Moko Consulting b4d8ff3336 refactor(workflows): rename secrets MOKOGITEA_TOKEN/GITHUB_TOKEN, use x-access-token [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-28 14:23:40 -05:00
Moko Consulting 003bd1624a fix(workflows): proper suffix handling — use version_set_platform instead of sed [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-28 14:15:20 -05:00
gitea-actions[bot] 13b6cf2016 chore: update development channel 01.00.07-dev [skip ci] 2026-05-28 19:04:24 +00:00
Jonathan Miller d70acbc35d docs: restructure CHANGELOG by feature area
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Reorganized from chronological bullet dump into structured sections:
Core Engine, Admin Component, Queue Processing, Per-Article Controls,
OAuth, Migration, Service Plugins (34 platforms by category), Plugin
Configuration, Infrastructure.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 14:02:00 -05:00
gitea-actions[bot] 3b089f8d72 chore: update development channel 01.00.07-dev [skip ci] 2026-05-28 19:00:06 +00:00
gitea-actions[bot] fc57c51004 chore(version): auto-bump 01.00.07-dev [skip ci] 2026-05-28 19:00:04 +00:00
Jonathan Miller 3b1b0e8844 feat: 25 expansion service plugins (#23-#47)
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 10s
Social Media: Threads (Meta), Pinterest, Reddit, Tumblr, TikTok,
Nostr, ActivityPub (generic Fediverse)

Chat/Messaging: Microsoft Teams, Google Chat, WhatsApp Business,
Matrix/Element, Ntfy (push notifications)

Email/Newsletter: SendGrid, Brevo (Sendinblue), ConvertKit,
Constant Contact

Publishing/Blogging: Medium, WordPress, Dev.to, Ghost, Hashnode,
Google Blogger

Business: Google Business Profile

Universal: Generic Webhook (IFTTT/Zapier/n8n/Make/custom endpoints),
RSS Feed (dedicated cross-post feed)

Each plugin implements MokoJoomCrossServiceInterface with publish(),
validateCredentials(), getServiceType(), getServiceName(),
getMaxLength(), supportsMedia(). Teams and Threads have default
MokoWaaS bot modes.

Package now contains 40 sub-extensions (1 component + 5 core
plugins + 34 service plugins). Service type dropdown organized
by category with all 34 platforms.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 13:59:54 -05:00
407 changed files with 11851 additions and 663 deletions
+1
View File
@@ -5,6 +5,7 @@
<org>MokoConsulting</org>
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms</description>
<version>01.00.06-dev-dev</version>
<version>01.00.13</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+5 -11
View File
@@ -37,7 +37,7 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
@@ -49,7 +49,7 @@ jobs:
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" \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
@@ -63,16 +63,10 @@ jobs:
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
# Propagate to platform manifests with -dev suffix
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch dev 2>/dev/null || true
--path . --version "$VERSION" --branch dev --stability dev 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Append -dev suffix to all manifest <version> tags
find . -maxdepth 4 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" \
-exec grep -l "<version>${VERSION}</version>" {} \; 2>/dev/null | while read f; do
sed -i "s|<version>${VERSION}</version>|<version>${VERSION}-dev</version>|g" "$f"
done
VERSION="${VERSION}-dev"
# Commit if anything changed
@@ -83,7 +77,7 @@ jobs:
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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
+71 -32
View File
@@ -63,17 +63,19 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
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
@@ -85,7 +87,7 @@ jobs:
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 }}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${API_BASE}" \
--branch "${{ github.event.pull_request.head.ref || 'dev' }}"
@@ -95,7 +97,7 @@ jobs:
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 }}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${API_BASE}"
- name: Summary
@@ -116,19 +118,27 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
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: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
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
@@ -155,6 +165,8 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Strip any pre-release suffix merged from dev (e.g. 01.02.20-dev → 01.02.20)
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
MAJOR=$(echo "$VERSION" | cut -d. -f1)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
@@ -167,7 +179,7 @@ jobs:
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 }}" \
RC_JSON=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_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)
@@ -189,6 +201,8 @@ jobs:
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 .)
# Strip any pre-release suffix — stable releases have no suffix
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Bumped to: ${VERSION}"
@@ -261,6 +275,18 @@ jobs:
# Step 5 (updates.xml) moved after Step 8 to include SHA-256 checksum
- name: "Step 4b: Promote and prune CHANGELOG"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
MOKO_API="/tmp/moko-platform-api/cli"
if [ -f "CHANGELOG.md" ]; then
php ${MOKO_API}/changelog_promote.php --path . --version "$VERSION" 2>&1 || true
php ${MOKO_API}/changelog_prune.php --path . --keep 5 2>&1 || true
fi
- name: Commit release changes
if: >-
steps.version.outputs.skip != 'true' &&
@@ -271,14 +297,11 @@ jobs:
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] <gitea-actions[bot]@mokoconsulting.tech>"
git push -u origin HEAD
# Detached HEAD on PR merge — push explicitly to main
git push origin HEAD:refs/heads/main
# -- STEP 6: Create tag ---------------------------------------------------
- name: "Step 6: Create git tag"
@@ -306,7 +329,7 @@ jobs:
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 }}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${API_BASE}" \
--path . --branch main
echo "Promoted RC → stable (${VERSION})" >> $GITHUB_STEP_SUMMARY
@@ -322,7 +345,7 @@ jobs:
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" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch main
echo "Release created: ${VERSION}" >> $GITHUB_STEP_SUMMARY
@@ -338,7 +361,7 @@ jobs:
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" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
# -- STEP 5: Write update stream (after build so SHA-256 is available) -----
@@ -349,9 +372,9 @@ jobs:
SHA256="${{ steps.package.outputs.sha256_zip }}"
# Fetch latest updates.xml from main so preserve logic has all channels
GA_TOKEN="${{ secrets.GA_TOKEN }}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
curl -sf -H "Authorization: token ${GA_TOKEN}" \
curl -sf -H "Authorization: token ${GITEA_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
@@ -366,13 +389,10 @@ jobs:
# 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] <gitea-actions[bot]@mokoconsulting.tech>"
git push origin HEAD 2>&1 || true
git push origin HEAD:refs/heads/main 2>&1 || true
fi
# -- STEP 8b: Update release description with changelog ----------------------
@@ -384,7 +404,7 @@ jobs:
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 }}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
2>&1 || true
echo "Release body updated" >> $GITHUB_STEP_SUMMARY
@@ -393,7 +413,7 @@ jobs:
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_TOKEN != ''
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
@@ -402,8 +422,8 @@ jobs:
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" \
--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
@@ -411,14 +431,14 @@ jobs:
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_TOKEN != ''
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_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 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" \
@@ -434,7 +454,7 @@ jobs:
php /tmp/moko-platform-api/cli/release_cascade.php \
--stability stable \
--version "${VERSION}" \
--token "${{ secrets.GA_TOKEN }}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${API_BASE}" 2>/dev/null || true
- name: "Step 11: Delete and recreate dev branch from main"
@@ -442,7 +462,7 @@ jobs:
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
@@ -456,6 +476,25 @@ jobs:
echo "Dev branch reset from main (keeps dev ahead after release)" >> $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"
@@ -464,7 +503,7 @@ jobs:
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}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
+33 -33
View File
@@ -8,21 +8,21 @@
# 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
# BRIEF: Forward-merge main all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN -> ALL BRANCHES |
# | 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 |
# | 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"
name: "Universal: Cascade Main Dev"
on:
push:
@@ -42,7 +42,7 @@ permissions:
jobs:
cascade:
name: Cascade main -> branches
name: Cascade main branches
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
@@ -52,7 +52,7 @@ jobs:
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
@@ -61,7 +61,7 @@ jobs:
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
@@ -83,17 +83,17 @@ jobs:
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo " No cascade target branches found"
echo " No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo " Found ${COUNT} target branch(es): ${TARGETS}"
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 }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
@@ -106,27 +106,27 @@ jobs:
for BRANCH in $TARGETS; do
echo ""
echo " main -> ${BRANCH} "
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}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " Already up to date"
echo " Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " main is ${AHEAD} commit(s) ahead"
echo " main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
@@ -134,16 +134,16 @@ jobs:
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " Reusing existing PR #${PR_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 "Authorization: token ${GITEA_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**.\",
\"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}\"
}" \
@@ -155,34 +155,34 @@ jobs:
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}"
echo " Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " Created PR #${PR_NUMBER}"
echo " Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " Conflicts -- PR #${PR_NUMBER} left open"
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 "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main -> ${BRANCH} [skip ci]\",
\"merge_message_field\": \"chore: cascade main ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
@@ -190,23 +190,23 @@ jobs:
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " Merged -- ${BRANCH} is in sync"
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"
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 ""
echo "════════════════════════════════════════"
echo " Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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 }}"
+13 -12
View File
@@ -50,16 +50,18 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
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
@@ -87,25 +89,24 @@ jobs:
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Strip any existing suffix from version before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
# Verify version consistency across all files
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Append suffix to all manifest <version> tags
# Update VERSION variable with suffix
if [ -n "$SUFFIX" ]; then
find . -maxdepth 4 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" \
-exec grep -l "<version>${VERSION}</version>" {} \; 2>/dev/null | while read f; do
sed -i "s|<version>${VERSION}</version>|<version>${VERSION}${SUFFIX}</version>|g" "$f"
done
VERSION="${VERSION}${SUFFIX}"
fi
# 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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_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]"
@@ -140,7 +141,7 @@ jobs:
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" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Build package and upload
@@ -151,7 +152,7 @@ jobs:
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" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
@@ -208,7 +209,7 @@ jobs:
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
+26 -27
View File
@@ -73,25 +73,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
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
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
@@ -106,16 +104,18 @@ jobs:
run: |
BRANCH="${{ github.ref_name }}"
# Auto-bump patch version
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Propagate version to all manifest files
php ${MOKO_CLI}/version_set_platform.php --path . --version "$VERSION" --branch "$BRANCH" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -139,12 +139,13 @@ jobs:
*) SUFFIX=""; TAG="stable" ;;
esac
# Append suffix to all manifest <version> tags (non-stable only)
# Propagate version with stability suffix to all manifest files
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform)
if [ -n "$SUFFIX" ]; then
find . -maxdepth 4 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" \
-exec grep -l "<version>${VERSION}</version>" {} \; 2>/dev/null | while read f; do
sed -i "s|<version>${VERSION}</version>|<version>${VERSION}${SUFFIX}</version>|g" "$f"
done
VERSION="${VERSION}${SUFFIX}"
fi
@@ -172,13 +173,13 @@ jobs:
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
@@ -202,8 +203,6 @@ jobs:
${SHA_FLAG}
# Commit and push updates.xml
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 ${STABILITY} channel ${VERSION} [skip ci]"
@@ -214,9 +213,9 @@ jobs:
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
@@ -234,7 +233,7 @@ jobs:
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GA_TOKEN}',
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
@@ -260,7 +259,7 @@ jobs:
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
+189 -43
View File
@@ -8,50 +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 `</field>` tag on webhook method field
- **Views**: Added missing `Toolbar` and `Route` imports in Logs, Posts, Services, Template, Templates HtmlView files
- **13 service plugins**: Fixed broken `publish()` methods that had literal placeholder URLs instead of using credential values — ActivityPub, Blogger, Ghost, Google Business, Hashnode, Matrix, Medium, Nostr, RSS Feed, Threads, Tumblr, WhatsApp, WordPress
- **Ghost**: Proper JWT auth from `{id}:{secret}` admin API key format
- **WordPress**: Correct Basic Auth (not Bearer) with Application Passwords
- **Medium**: 2-step flow — fetch user ID via /v1/me, then post
- **Matrix**: PUT with transaction ID for idempotent message sending
- **Hashnode**: GraphQL mutation with proper query structure
- **Threads**: 2-step container creation + publish flow
- **WhatsApp**: Meta Cloud API with messaging_product payload
- **Nostr**: Stub with clear "not yet implemented" message (requires WebSocket)
- **RSS Feed**: Local service — no external API, always succeeds
### Added
- **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
- Core cross-posting engine: service plugin dispatch, duplicate guard, immediate execution
- System plugin listens to both `onContentAfterSave` and `onContentChangeState` for publish events
- Full admin templates: services list, post queue list, activity logs list, dashboard with recent activity
- Service edit form with default/custom mode toggle and credential fields
- Dashboard migration controller action for Perfect Publisher Pro import
- Template placeholders: {title}, {url}, {introtext}, {fulltext}, {image}, {category}, {author}, {date}
- Queue processing: Joomla Scheduled Task plugin (`plg_task_mokojoomcross`)preferred method
- Queue processing: Page-load fallback via system plugin `onAfterRender` with configurable throttle
- Configurable processing method: scheduler-only (recommended), page-load only, or both
- Dashboard warning banner when page-load processing is active instead of scheduler
- `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 delay
- Failed post retry with configurable max retries and exponential delay
- Scheduled post support (`scheduled_at` column)
- Automatic log cleanup based on configurable retention period
- PP Pro migration rewritten: reads #__autotweet_channels table with credential mapping per service type
- PP Pro migration fallback: extracts from component params when channel table missing
- Plugin-level config forms for Telegram, Facebook, Discord, Slack (default bot tokens stored in plugin params)
- Telegram plugin config: default bot token, parse mode, link preview toggle
- Facebook plugin config: default page access token, default page ID
- Discord plugin config: default webhook URL, embed color
- Slack plugin config: default webhook URL
- LinkedIn plugin config: OAuth client ID/secret, redirect URI
- Mastodon plugin config: default instance URL, visibility, hashtags
- Bluesky plugin config: default PDS URL, auto link cards
- Mailchimp plugin config: default sender name/email, auto-send toggle
- Template management: full CRUD with list/edit views, placeholder reference panel
- Templates submenu item and dashboard quick link
- Logs filter form with level and search filters
- Admin component now has 5 submenu items: Dashboard, Post Queue, Services, Templates, Logs
- Per-article cross-posting: skip toggle and service checkboxes in article editor attribs tab
- Content plugin injects dynamic "Cross-Posting" fieldset via onContentPrepareForm
- System plugin reads article attribs for mokojoomcross_services and mokojoomcross_skip
- Analytics dashboard: posts-by-service table with success rates, top articles, daily trend data
- OAuth helper: authorization URL generation, PKCE for Twitter, code exchange, token storage
- OAuth controller: authorize and callback endpoints for Facebook, LinkedIn, Twitter
- Wiki: Services guide, REST API reference, Message Templates, Troubleshooting
#### 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
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomCross
<!-- VERSION: 01.00.06-dev -->
<!-- VERSION: 01.00.24 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
+56
View File
@@ -12,6 +12,18 @@
<option value="0">JNO</option>
</field>
<field
name="post_on_first_publish_only"
type="radio"
label="COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY"
description="COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC"
default="0"
class="btn-group"
showon="auto_post_on_publish:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="retry_max"
type="number"
@@ -52,6 +64,41 @@
/>
</fieldset>
<fieldset name="evergreen" label="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN">
<field
name="evergreen_enabled"
type="radio"
label="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_ENABLED"
description="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_ENABLED_DESC"
default="1"
class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="evergreen_default_interval"
type="number"
label="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_DEFAULT_INTERVAL"
description="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_DEFAULT_INTERVAL_DESC"
default="30"
min="1"
max="365"
showon="evergreen_enabled:1"
/>
<field
name="evergreen_max_per_run"
type="number"
label="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_MAX_PER_RUN"
description="COM_MOKOJOOMCROSS_CONFIG_EVERGREEN_MAX_PER_RUN_DESC"
default="3"
min="1"
max="20"
showon="evergreen_enabled:1"
/>
</fieldset>
<fieldset name="queue" label="COM_MOKOJOOMCROSS_CONFIG_QUEUE">
<field
name="queue_processing"
@@ -87,4 +134,13 @@
showon="queue_processing:pageload,both"
/>
</fieldset>
<fieldset name="category_rules" label="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES">
<field
name="category_rules_note"
type="note"
label="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE"
description="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE_DESC"
/>
</fieldset>
</config>
@@ -20,6 +20,19 @@
<option value="failed">Failed</option>
<option value="scheduled">Scheduled</option>
</field>
<field
name="service_id"
type="sql"
label="COM_MOKOJOOMCROSS_FILTER_SERVICE_TYPE"
onchange="this.form.submit();"
sql_select="id, CONCAT(title, ' (', service_type, ')') AS title"
sql_from="#__mokojoomcross_services"
key_field="id"
value_field="title"
sql_order="ordering ASC">
<option value="">COM_MOKOJOOMCROSS_SELECT_SERVICE</option>
</field>
</fields>
<fields name="list">
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="details">
<field
name="id"
type="hidden"
/>
<field
name="article_id"
type="sql"
label="COM_MOKOJOOMCROSS_POST_ARTICLE"
description="COM_MOKOJOOMCROSS_POST_ARTICLE_DESC"
required="true"
sql_select="id, title"
sql_from="#__content"
sql_filter="true"
sql_default_title="- Select Article -"
key_field="id"
value_field="title"
sql_order="title ASC"
>
<option value="">COM_MOKOJOOMCROSS_SELECT_ARTICLE</option>
</field>
<field
name="service_id"
type="sql"
label="COM_MOKOJOOMCROSS_POST_SERVICE"
description="COM_MOKOJOOMCROSS_POST_SERVICE_DESC"
required="true"
sql_select="id, CONCAT(title, ' (', service_type, ')') AS title"
sql_from="#__mokojoomcross_services"
sql_filter="true"
sql_default_title="- Select Service -"
sql_where="published = 1"
key_field="id"
value_field="title"
sql_order="ordering ASC"
>
<option value="">COM_MOKOJOOMCROSS_SELECT_SERVICE</option>
</field>
<field
name="message"
type="textarea"
label="COM_MOKOJOOMCROSS_POST_MESSAGE"
description="COM_MOKOJOOMCROSS_POST_MESSAGE_DESC"
rows="6"
cols="60"
required="true"
/>
<field
name="status"
type="list"
label="COM_MOKOJOOMCROSS_POST_STATUS"
default="queued">
<option value="queued">COM_MOKOJOOMCROSS_STATUS_QUEUED</option>
<option value="scheduled">COM_MOKOJOOMCROSS_STATUS_SCHEDULED</option>
<option value="posted">COM_MOKOJOOMCROSS_STATUS_POSTED</option>
<option value="failed">COM_MOKOJOOMCROSS_STATUS_FAILED</option>
</field>
<field
name="scheduled_at"
type="calendar"
label="COM_MOKOJOOMCROSS_POST_SCHEDULED_AT"
description="COM_MOKOJOOMCROSS_POST_SCHEDULED_AT_DESC"
showtime="true"
format="%Y-%m-%d %H:%M:%S"
/>
</fieldset>
<fieldset name="readonly" label="COM_MOKOJOOMCROSS_POST_RESULTS">
<field
name="platform_post_id"
type="text"
label="COM_MOKOJOOMCROSS_POST_PLATFORM_ID"
readonly="true"
/>
<field
name="error_message"
type="textarea"
label="COM_MOKOJOOMCROSS_POST_ERROR"
readonly="true"
rows="3"
/>
<field
name="retry_count"
type="number"
label="COM_MOKOJOOMCROSS_POST_RETRY_COUNT"
readonly="true"
/>
<field
name="posted_at"
type="calendar"
label="COM_MOKOJOOMCROSS_POST_POSTED_AT"
readonly="true"
showtime="true"
format="%Y-%m-%d %H:%M:%S"
/>
<field
name="created"
type="calendar"
label="JGLOBAL_CREATED"
readonly="true"
showtime="true"
format="%Y-%m-%d %H:%M:%S"
/>
<field
name="modified"
type="calendar"
label="JGLOBAL_MODIFIED"
readonly="true"
showtime="true"
format="%Y-%m-%d %H:%M:%S"
/>
</fieldset>
</form>
@@ -28,15 +28,46 @@
required="true"
default="">
<option value="">COM_MOKOJOOMCROSS_SELECT_SERVICE_TYPE</option>
<!-- Social Media -->
<option value="facebook">Facebook / Meta</option>
<option value="twitter">X / Twitter</option>
<option value="linkedin">LinkedIn</option>
<option value="mastodon">Mastodon</option>
<option value="bluesky">Bluesky</option>
<option value="mailchimp">Mailchimp</option>
<option value="threads">Threads (Meta)</option>
<option value="pinterest">Pinterest</option>
<option value="reddit">Reddit</option>
<option value="tumblr">Tumblr</option>
<option value="tiktok">TikTok</option>
<option value="nostr">Nostr</option>
<option value="activitypub">ActivityPub (Fediverse)</option>
<!-- Chat / Messaging -->
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="teams">Microsoft Teams</option>
<option value="googlechat">Google Chat</option>
<option value="whatsapp">WhatsApp Business</option>
<option value="matrix">Matrix / Element</option>
<option value="ntfy">Ntfy (Push Notifications)</option>
<!-- Email / Newsletter -->
<option value="mailchimp">Mailchimp</option>
<option value="sendgrid">SendGrid</option>
<option value="brevo">Brevo (Sendinblue)</option>
<option value="convertkit">ConvertKit</option>
<option value="constantcontact">Constant Contact</option>
<!-- Publishing / Blogging -->
<option value="medium">Medium</option>
<option value="wordpress">WordPress</option>
<option value="devto">Dev.to</option>
<option value="ghost">Ghost</option>
<option value="hashnode">Hashnode</option>
<option value="blogger">Google Blogger</option>
<!-- Business -->
<option value="googlebusiness">Google Business Profile</option>
<!-- Other -->
<option value="webhook">Generic Webhook</option>
<option value="rssfeed">RSS Feed</option>
</field>
<field
@@ -55,14 +86,825 @@
/>
</fieldset>
<!-- ============================================================ -->
<!-- Per-service credential fields using showon -->
<!-- ============================================================ -->
<!-- Mode selector for services with default bot support -->
<fieldset name="credentials" label="COM_MOKOJOOMCROSS_FIELDSET_CREDENTIALS">
<field
name="credentials"
name="cred_mode"
type="list"
label="COM_MOKOJOOMCROSS_FIELD_CRED_MODE"
description="COM_MOKOJOOMCROSS_FIELD_CRED_MODE_DESC"
default="default"
showon="service_type:telegram,discord,slack,teams,facebook,threads">
<option value="default">COM_MOKOJOOMCROSS_CRED_MODE_DEFAULT</option>
<option value="custom">COM_MOKOJOOMCROSS_CRED_MODE_CUSTOM</option>
</field>
<!-- ======== TELEGRAM ======== -->
<field
name="cred_telegram_chat_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_TELEGRAM_CHAT_ID"
description="COM_MOKOJOOMCROSS_CRED_TELEGRAM_CHAT_ID_DESC"
showon="service_type:telegram"
size="40"
/>
<field
name="cred_telegram_bot_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TELEGRAM_BOT_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_TELEGRAM_BOT_TOKEN_DESC"
showon="service_type:telegram[AND]cred_mode:custom"
size="60"
/>
<!-- ======== DISCORD ======== -->
<field
name="cred_discord_webhook_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_DISCORD_WEBHOOK"
description="COM_MOKOJOOMCROSS_CRED_DISCORD_WEBHOOK_DESC"
showon="service_type:discord[AND]cred_mode:custom"
size="80"
/>
<field
name="cred_discord_username"
type="text"
label="COM_MOKOJOOMCROSS_CRED_DISCORD_USERNAME"
description="COM_MOKOJOOMCROSS_CRED_DISCORD_USERNAME_DESC"
showon="service_type:discord"
size="40"
/>
<field
name="cred_discord_avatar_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_DISCORD_AVATAR"
description="COM_MOKOJOOMCROSS_CRED_DISCORD_AVATAR_DESC"
showon="service_type:discord"
size="80"
/>
<!-- ======== SLACK ======== -->
<field
name="cred_slack_webhook_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_SLACK_WEBHOOK"
description="COM_MOKOJOOMCROSS_CRED_SLACK_WEBHOOK_DESC"
showon="service_type:slack[AND]cred_mode:custom"
size="80"
/>
<!-- ======== MICROSOFT TEAMS ======== -->
<field
name="cred_teams_webhook_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_TEAMS_WEBHOOK"
description="COM_MOKOJOOMCROSS_CRED_TEAMS_WEBHOOK_DESC"
showon="service_type:teams[AND]cred_mode:custom"
size="80"
/>
<!-- ======== GOOGLE CHAT ======== -->
<field
name="cred_googlechat_webhook_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_GOOGLECHAT_WEBHOOK"
description="COM_MOKOJOOMCROSS_CRED_GOOGLECHAT_WEBHOOK_DESC"
showon="service_type:googlechat"
size="80"
/>
<!-- ======== FACEBOOK ======== -->
<field
name="cred_facebook_page_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_FACEBOOK_PAGE_ID"
description="COM_MOKOJOOMCROSS_CRED_FACEBOOK_PAGE_ID_DESC"
showon="service_type:facebook"
size="40"
/>
<field
name="cred_facebook_page_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_FACEBOOK_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_FACEBOOK_TOKEN_DESC"
showon="service_type:facebook[AND]cred_mode:custom"
size="60"
/>
<!-- ======== THREADS ======== -->
<field
name="cred_threads_user_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_THREADS_USER_ID"
showon="service_type:threads"
size="40"
/>
<field
name="cred_threads_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_THREADS_TOKEN"
showon="service_type:threads[AND]cred_mode:custom"
size="60"
/>
<!-- ======== TWITTER / X (OAuth 1.0a — 4 keys required for posting) ======== -->
<field
name="cred_twitter_api_key"
type="text"
label="COM_MOKOJOOMCROSS_CRED_TWITTER_API_KEY"
description="COM_MOKOJOOMCROSS_CRED_TWITTER_API_KEY_DESC"
showon="service_type:twitter"
size="40"
/>
<field
name="cred_twitter_api_secret"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TWITTER_API_SECRET"
description="COM_MOKOJOOMCROSS_CRED_TWITTER_API_SECRET_DESC"
showon="service_type:twitter"
size="40"
/>
<field
name="cred_twitter_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_DESC"
showon="service_type:twitter"
size="60"
/>
<field
name="cred_twitter_access_token_secret"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_SECRET"
description="COM_MOKOJOOMCROSS_CRED_TWITTER_ACCESS_TOKEN_SECRET_DESC"
showon="service_type:twitter"
size="60"
/>
<!-- ======== LINKEDIN ======== -->
<field
name="cred_linkedin_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_LINKEDIN_TOKEN"
showon="service_type:linkedin"
size="60"
/>
<field
name="cred_linkedin_organization_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_LINKEDIN_ORG_ID"
description="COM_MOKOJOOMCROSS_CRED_LINKEDIN_ORG_ID_DESC"
showon="service_type:linkedin"
size="40"
/>
<field
name="cred_linkedin_refresh_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_LINKEDIN_REFRESH_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_LINKEDIN_REFRESH_TOKEN_DESC"
showon="service_type:linkedin"
size="60"
/>
<!-- ======== MASTODON ======== -->
<field
name="cred_mastodon_instance_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_MASTODON_INSTANCE"
description="COM_MOKOJOOMCROSS_CRED_MASTODON_INSTANCE_DESC"
showon="service_type:mastodon"
size="40"
default="https://mastodon.social"
/>
<field
name="cred_mastodon_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_MASTODON_TOKEN"
showon="service_type:mastodon"
size="60"
/>
<!-- ======== BLUESKY ======== -->
<field
name="cred_bluesky_handle"
type="text"
label="COM_MOKOJOOMCROSS_CRED_BLUESKY_HANDLE"
description="COM_MOKOJOOMCROSS_CRED_BLUESKY_HANDLE_DESC"
showon="service_type:bluesky"
size="40"
/>
<field
name="cred_bluesky_app_password"
type="password"
label="COM_MOKOJOOMCROSS_CRED_BLUESKY_APP_PWD"
description="COM_MOKOJOOMCROSS_CRED_BLUESKY_APP_PWD_DESC"
showon="service_type:bluesky"
size="40"
/>
<field
name="cred_bluesky_pds_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_BLUESKY_PDS_URL"
description="COM_MOKOJOOMCROSS_CRED_BLUESKY_PDS_URL_DESC"
showon="service_type:bluesky"
size="40"
default="https://bsky.social"
/>
<!-- ======== WHATSAPP ======== -->
<field
name="cred_whatsapp_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_WHATSAPP_TOKEN"
showon="service_type:whatsapp"
size="60"
/>
<field
name="cred_whatsapp_phone_number_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_WHATSAPP_PHONE_ID"
showon="service_type:whatsapp"
size="40"
/>
<field
name="cred_whatsapp_recipient"
type="text"
label="COM_MOKOJOOMCROSS_CRED_WHATSAPP_RECIPIENT"
description="COM_MOKOJOOMCROSS_CRED_WHATSAPP_RECIPIENT_DESC"
showon="service_type:whatsapp"
size="40"
/>
<!-- ======== MAILCHIMP ======== -->
<field
name="cred_mailchimp_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_KEY"
description="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_KEY_DESC"
showon="service_type:mailchimp"
size="60"
/>
<field
name="cred_mailchimp_list_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_LIST"
description="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_LIST_DESC"
showon="service_type:mailchimp"
size="40"
/>
<field
name="cred_mailchimp_from_name"
type="text"
label="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_NAME"
description="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_NAME_DESC"
showon="service_type:mailchimp"
size="40"
/>
<field
name="cred_mailchimp_from_email"
type="email"
label="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_EMAIL"
description="COM_MOKOJOOMCROSS_CRED_MAILCHIMP_FROM_EMAIL_DESC"
showon="service_type:mailchimp"
size="40"
/>
<!-- ======== SENDGRID ======== -->
<field
name="cred_sendgrid_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_SENDGRID_KEY"
showon="service_type:sendgrid"
size="60"
/>
<field
name="cred_sendgrid_list_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_SENDGRID_LIST"
showon="service_type:sendgrid"
size="40"
/>
<field
name="cred_sendgrid_from_email"
type="email"
label="COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_EMAIL"
description="COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_EMAIL_DESC"
showon="service_type:sendgrid"
size="40"
/>
<field
name="cred_sendgrid_from_name"
type="text"
label="COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_NAME"
description="COM_MOKOJOOMCROSS_CRED_SENDGRID_FROM_NAME_DESC"
showon="service_type:sendgrid"
size="40"
/>
<!-- ======== GENERIC WEBHOOK ======== -->
<field
name="cred_webhook_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_URL"
description="COM_MOKOJOOMCROSS_CRED_WEBHOOK_URL_DESC"
showon="service_type:webhook"
size="80"
required="true"
/>
<field
name="cred_webhook_method"
type="list"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_METHOD"
showon="service_type:webhook"
default="POST">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
</field>
<field
name="cred_webhook_auth_type"
type="list"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_AUTH_TYPE"
description="COM_MOKOJOOMCROSS_CRED_WEBHOOK_AUTH_TYPE_DESC"
showon="service_type:webhook"
default="none">
<option value="none">COM_MOKOJOOMCROSS_WEBHOOK_AUTH_NONE</option>
<option value="bearer">COM_MOKOJOOMCROSS_WEBHOOK_AUTH_BEARER</option>
<option value="basic">COM_MOKOJOOMCROSS_WEBHOOK_AUTH_BASIC</option>
</field>
<field
name="cred_webhook_bearer_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_BEARER_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_WEBHOOK_BEARER_TOKEN_DESC"
showon="service_type:webhook[AND]cred_webhook_auth_type:bearer"
size="60"
/>
<field
name="cred_webhook_basic_username"
type="text"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_BASIC_USER"
showon="service_type:webhook[AND]cred_webhook_auth_type:basic"
size="40"
/>
<field
name="cred_webhook_basic_password"
type="password"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_BASIC_PWD"
showon="service_type:webhook[AND]cred_webhook_auth_type:basic"
size="40"
/>
<field
name="cred_webhook_content_type"
type="list"
label="COM_MOKOJOOMCROSS_CRED_WEBHOOK_CONTENT_TYPE"
showon="service_type:webhook"
default="json">
<option value="json">application/json</option>
<option value="form">application/x-www-form-urlencoded</option>
</field>
<!-- ======== MATRIX ======== -->
<field
name="cred_matrix_homeserver"
type="url"
label="COM_MOKOJOOMCROSS_CRED_MATRIX_HOMESERVER"
showon="service_type:matrix"
size="40"
default="https://matrix.org"
/>
<field
name="cred_matrix_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_MATRIX_TOKEN"
showon="service_type:matrix"
size="60"
/>
<field
name="cred_matrix_room_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_MATRIX_ROOM"
description="COM_MOKOJOOMCROSS_CRED_MATRIX_ROOM_DESC"
showon="service_type:matrix"
size="40"
/>
<!-- ======== NTFY ======== -->
<field
name="cred_ntfy_server_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_NTFY_SERVER"
showon="service_type:ntfy"
size="40"
default="https://ntfy.sh"
/>
<field
name="cred_ntfy_topic"
type="text"
label="COM_MOKOJOOMCROSS_CRED_NTFY_TOPIC"
description="COM_MOKOJOOMCROSS_CRED_NTFY_TOPIC_DESC"
showon="service_type:ntfy"
size="40"
required="true"
/>
<field
name="cred_ntfy_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_NTFY_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_NTFY_TOKEN_DESC"
showon="service_type:ntfy"
size="40"
/>
<!-- ======== WORDPRESS ======== -->
<field
name="cred_wordpress_site_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_WP_SITE"
showon="service_type:wordpress"
size="40"
/>
<field
name="cred_wordpress_username"
type="text"
label="COM_MOKOJOOMCROSS_CRED_WP_USER"
showon="service_type:wordpress"
size="40"
/>
<field
name="cred_wordpress_app_password"
type="password"
label="COM_MOKOJOOMCROSS_CRED_WP_APP_PWD"
description="COM_MOKOJOOMCROSS_CRED_WP_APP_PWD_DESC"
showon="service_type:wordpress"
size="40"
/>
<field
name="cred_wordpress_default_status"
type="list"
label="COM_MOKOJOOMCROSS_CRED_WP_DEFAULT_STATUS"
description="COM_MOKOJOOMCROSS_CRED_WP_DEFAULT_STATUS_DESC"
showon="service_type:wordpress"
default="draft">
<option value="draft">COM_MOKOJOOMCROSS_STATUS_DRAFT</option>
<option value="publish">COM_MOKOJOOMCROSS_STATUS_PUBLISH</option>
</field>
<!-- ======== MEDIUM ======== -->
<field
name="cred_medium_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_MEDIUM_TOKEN"
showon="service_type:medium"
size="60"
/>
<!-- ======== DEV.TO ======== -->
<field
name="cred_devto_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_DEVTO_KEY"
showon="service_type:devto"
size="60"
/>
<field
name="cred_devto_organization_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_DEVTO_ORG_ID"
description="COM_MOKOJOOMCROSS_CRED_DEVTO_ORG_ID_DESC"
showon="service_type:devto"
size="40"
/>
<!-- ======== GHOST ======== -->
<field
name="cred_ghost_site_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_GHOST_SITE"
showon="service_type:ghost"
size="40"
/>
<field
name="cred_ghost_admin_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_GHOST_KEY"
showon="service_type:ghost"
size="60"
/>
<field
name="cred_ghost_default_status"
type="list"
label="COM_MOKOJOOMCROSS_CRED_GHOST_DEFAULT_STATUS"
description="COM_MOKOJOOMCROSS_CRED_GHOST_DEFAULT_STATUS_DESC"
showon="service_type:ghost"
default="draft">
<option value="draft">COM_MOKOJOOMCROSS_STATUS_DRAFT</option>
<option value="published">COM_MOKOJOOMCROSS_STATUS_PUBLISHED</option>
</field>
<!-- ======== REDDIT ======== -->
<field
name="cred_reddit_client_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_REDDIT_CLIENT_ID"
showon="service_type:reddit"
size="40"
/>
<field
name="cred_reddit_client_secret"
type="password"
label="COM_MOKOJOOMCROSS_CRED_REDDIT_SECRET"
showon="service_type:reddit"
size="40"
/>
<field
name="cred_reddit_username"
type="text"
label="COM_MOKOJOOMCROSS_CRED_REDDIT_USER"
showon="service_type:reddit"
size="40"
/>
<field
name="cred_reddit_password"
type="password"
label="COM_MOKOJOOMCROSS_CRED_REDDIT_PASSWORD"
description="COM_MOKOJOOMCROSS_CRED_REDDIT_PASSWORD_DESC"
showon="service_type:reddit"
size="40"
/>
<field
name="cred_reddit_subreddit"
type="text"
label="COM_MOKOJOOMCROSS_CRED_REDDIT_SUBREDDIT"
description="COM_MOKOJOOMCROSS_CRED_REDDIT_SUBREDDIT_DESC"
showon="service_type:reddit"
size="40"
/>
<!-- ======== PINTEREST ======== -->
<field
name="cred_pinterest_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_PINTEREST_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_PINTEREST_TOKEN_DESC"
showon="service_type:pinterest"
size="60"
/>
<field
name="cred_pinterest_board_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_PINTEREST_BOARD"
description="COM_MOKOJOOMCROSS_CRED_PINTEREST_BOARD_DESC"
showon="service_type:pinterest"
size="40"
/>
<!-- ======== TUMBLR ======== -->
<field
name="cred_tumblr_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TUMBLR_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_TUMBLR_TOKEN_DESC"
showon="service_type:tumblr"
size="60"
/>
<field
name="cred_tumblr_blog_name"
type="text"
label="COM_MOKOJOOMCROSS_CRED_TUMBLR_BLOG"
description="COM_MOKOJOOMCROSS_CRED_TUMBLR_BLOG_DESC"
showon="service_type:tumblr"
size="40"
/>
<!-- ======== TIKTOK ======== -->
<field
name="cred_tiktok_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TIKTOK_TOKEN"
showon="service_type:tiktok"
size="60"
/>
<field
name="cred_tiktok_refresh_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_TIKTOK_REFRESH_TOKEN"
showon="service_type:tiktok"
size="60"
/>
<field
name="cred_tiktok_open_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_TIKTOK_OPEN_ID"
description="COM_MOKOJOOMCROSS_CRED_TIKTOK_OPEN_ID_DESC"
showon="service_type:tiktok"
size="40"
/>
<!-- ======== NOSTR ======== -->
<field
name="cred_nostr_private_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_NOSTR_PRIVKEY"
description="COM_MOKOJOOMCROSS_CRED_NOSTR_PRIVKEY_DESC"
showon="service_type:nostr"
size="60"
/>
<field
name="cred_nostr_relays"
type="textarea"
label="COM_MOKOJOOMCROSS_FIELD_CREDENTIALS"
description="COM_MOKOJOOMCROSS_FIELD_CREDENTIALS_DESC"
rows="6"
filter="raw"
label="COM_MOKOJOOMCROSS_CRED_NOSTR_RELAYS"
description="COM_MOKOJOOMCROSS_CRED_NOSTR_RELAYS_DESC"
showon="service_type:nostr"
rows="3"
cols="60"
/>
<!-- ======== ACTIVITYPUB (Fediverse) ======== -->
<field
name="cred_activitypub_instance_url"
type="url"
label="COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_INSTANCE"
description="COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_INSTANCE_DESC"
showon="service_type:activitypub"
size="40"
/>
<field
name="cred_activitypub_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_TOKEN"
description="COM_MOKOJOOMCROSS_CRED_ACTIVITYPUB_TOKEN_DESC"
showon="service_type:activitypub"
size="60"
/>
<!-- ======== BREVO (Sendinblue) ======== -->
<field
name="cred_brevo_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_BREVO_KEY"
showon="service_type:brevo"
size="60"
/>
<field
name="cred_brevo_list_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_BREVO_LIST"
description="COM_MOKOJOOMCROSS_CRED_BREVO_LIST_DESC"
showon="service_type:brevo"
size="40"
/>
<field
name="cred_brevo_sender_email"
type="email"
label="COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_EMAIL"
description="COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_EMAIL_DESC"
showon="service_type:brevo"
size="40"
/>
<field
name="cred_brevo_sender_name"
type="text"
label="COM_MOKOJOOMCROSS_CRED_BREVO_SENDER_NAME"
showon="service_type:brevo"
size="40"
/>
<!-- ======== CONVERTKIT ======== -->
<field
name="cred_convertkit_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_CONVERTKIT_KEY"
showon="service_type:convertkit"
size="60"
/>
<field
name="cred_convertkit_api_secret"
type="password"
label="COM_MOKOJOOMCROSS_CRED_CONVERTKIT_SECRET"
showon="service_type:convertkit"
size="60"
/>
<!-- ======== CONSTANT CONTACT ======== -->
<field
name="cred_constantcontact_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_TOKEN"
showon="service_type:constantcontact"
size="60"
/>
<field
name="cred_constantcontact_refresh_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_REFRESH_TOKEN"
showon="service_type:constantcontact"
size="60"
/>
<field
name="cred_constantcontact_list_ids"
type="text"
label="COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_LISTS"
description="COM_MOKOJOOMCROSS_CRED_CONSTANTCONTACT_LISTS_DESC"
showon="service_type:constantcontact"
size="40"
/>
<!-- ======== HASHNODE ======== -->
<field
name="cred_hashnode_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_HASHNODE_TOKEN"
showon="service_type:hashnode"
size="60"
/>
<field
name="cred_hashnode_publication_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_HASHNODE_PUB_ID"
description="COM_MOKOJOOMCROSS_CRED_HASHNODE_PUB_ID_DESC"
showon="service_type:hashnode"
size="40"
/>
<!-- ======== GOOGLE BLOGGER ======== -->
<field
name="cred_blogger_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_BLOGGER_TOKEN"
showon="service_type:blogger"
size="60"
/>
<field
name="cred_blogger_refresh_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_BLOGGER_REFRESH_TOKEN"
showon="service_type:blogger"
size="60"
/>
<field
name="cred_blogger_blog_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_BLOGGER_BLOG_ID"
description="COM_MOKOJOOMCROSS_CRED_BLOGGER_BLOG_ID_DESC"
showon="service_type:blogger"
size="40"
/>
<!-- ======== GOOGLE BUSINESS PROFILE ======== -->
<field
name="cred_googlebusiness_access_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_GBUSINESS_TOKEN"
showon="service_type:googlebusiness"
size="60"
/>
<field
name="cred_googlebusiness_refresh_token"
type="password"
label="COM_MOKOJOOMCROSS_CRED_GBUSINESS_REFRESH_TOKEN"
showon="service_type:googlebusiness"
size="60"
/>
<field
name="cred_googlebusiness_location_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_GBUSINESS_LOCATION"
description="COM_MOKOJOOMCROSS_CRED_GBUSINESS_LOCATION_DESC"
showon="service_type:googlebusiness"
size="40"
/>
<field
name="cred_googlebusiness_account_id"
type="text"
label="COM_MOKOJOOMCROSS_CRED_GBUSINESS_ACCOUNT"
description="COM_MOKOJOOMCROSS_CRED_GBUSINESS_ACCOUNT_DESC"
showon="service_type:googlebusiness"
size="40"
/>
<!-- ======== RSS FEED ======== -->
<field
name="cred_rssfeed_title"
type="text"
label="COM_MOKOJOOMCROSS_CRED_RSSFEED_TITLE"
description="COM_MOKOJOOMCROSS_CRED_RSSFEED_TITLE_DESC"
showon="service_type:rssfeed"
size="40"
/>
<field
name="cred_rssfeed_max_items"
type="number"
label="COM_MOKOJOOMCROSS_CRED_RSSFEED_MAX_ITEMS"
description="COM_MOKOJOOMCROSS_CRED_RSSFEED_MAX_ITEMS_DESC"
showon="service_type:rssfeed"
default="50"
min="1"
max="500"
/>
</fieldset>
</form>
@@ -76,6 +76,15 @@ 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 <strong>MokoJoomCross - Process Queue</strong> in System → Scheduled Tasks, then set queue processing to <strong>Scheduler only</strong> 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"
@@ -133,3 +142,372 @@ COM_MOKOJOOMCROSS_OAUTH_INVALID_CALLBACK="Invalid OAuth callback — missing cod
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."
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade">
<name>com_mokojoomcross</name>
<version>01.00.06-dev-dev</version>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -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', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW());
('mailchimp', 'Mailchimp Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW()),
('telegram', 'Telegram Default', '<b>{title}</b>\n\n{introtext}\n\n<a href=\"{url}\">Read more</a>', 1, 5, NOW()),
('discord', 'Discord Default', '**{title}**\n\n{introtext}\n\n{url}', 1, 6, NOW()),
('slack', 'Slack Default', '*{title}*\n\n{introtext}\n\n{url}', 1, 7, NOW()),
('facebook', 'Facebook Default', '{title}\n\n{introtext}\n\n{url}', 1, 8, NOW()),
('linkedin', 'LinkedIn Default', '{title}\n\n{introtext}\n\n{url}', 1, 9, NOW()),
('bluesky', 'Bluesky Default', '{title}\n\n{url}', 1, 10, NOW()),
('threads', 'Threads Default', '{title}\n\n{introtext}\n\n{url}', 1, 11, NOW()),
('teams', 'Teams Default', '**{title}**\n\n{introtext}\n\n[Read more]({url})', 1, 12, NOW()),
('medium', 'Medium Default', '{title}\n\n{introtext}\n\n{url}', 1, 13, NOW()),
('wordpress', 'WordPress Default', '{title}\n\n{introtext}\n\n{url}', 1, 14, NOW()),
('webhook', 'Webhook Default', '{title}\n\n{introtext}\n\n{url}', 1, 15, NOW()),
('sendgrid', 'SendGrid Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, NOW()),
('brevo', 'Brevo Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, NOW()),
('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()),
('reddit', 'Reddit Default', '{title}', 1, 19, NOW()),
('pinterest', 'Pinterest Default', '{title} - {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;
@@ -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;
@@ -0,0 +1,351 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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();
}
}
@@ -13,6 +13,7 @@ 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;
@@ -84,7 +85,11 @@ class OauthController extends BaseController
return;
}
$url = OAuthHelper::getAuthorizeUrl($service->service_type, $serviceId, $clientId);
// 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(
@@ -133,6 +138,7 @@ class OauthController extends BaseController
$stateData = json_decode(base64_decode($state), true);
$serviceId = (int) ($stateData['service_id'] ?? 0);
$serviceType = $stateData['type'] ?? '';
$stateNonce = $stateData['nonce'] ?? '';
if (!$serviceId || !$serviceType) {
$this->setRedirect(
@@ -144,6 +150,21 @@ class OauthController extends BaseController
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);
@@ -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'
);
}
}
@@ -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();
}
}
@@ -0,0 +1,451 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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);
}
}
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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'
);
}
}
@@ -61,7 +61,7 @@ class OAuthHelper
*
* @return string|null Authorization URL or null if not supported
*/
public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId): ?string
public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId, string $nonce = ''): ?string
{
$config = self::OAUTH_CONFIGS[$serviceType] ?? null;
@@ -70,7 +70,13 @@ class OAuthHelper
}
$redirectUri = self::getCallbackUrl();
$state = base64_encode(json_encode(['service_id' => $serviceId, 'type' => $serviceType]));
$statePayload = ['service_id' => $serviceId, 'type' => $serviceType];
if (!empty($nonce)) {
$statePayload['nonce'] = $nonce;
}
$state = base64_encode(json_encode($statePayload));
$params = [
'client_id' => $clientId,
@@ -192,6 +198,107 @@ class OAuthHelper
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.
*
@@ -16,6 +16,7 @@ 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;
/**
@@ -71,9 +72,8 @@ class QueueProcessor
$db->setQuery($query);
$queuedPosts = $db->loadObjectList() ?: [];
// 2. Process failed posts eligible for retry
$retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql();
// 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'))
@@ -81,7 +81,8 @@ class QueueProcessor
. ' 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') . ' <= ' . $db->quote($retryAfter))
->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);
@@ -104,9 +105,28 @@ class QueueProcessor
$isRetry = ($post->status === 'failed');
if ($isRetry) {
// Increment retry count
$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'))
@@ -129,8 +149,60 @@ class QueueProcessor
$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 {
$apiResult = $plugin->publish($post->message, [], $credentials, $params);
$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(
@@ -148,6 +220,14 @@ class QueueProcessor
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'] ?? []);
@@ -166,6 +246,14 @@ class QueueProcessor
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) {
@@ -182,6 +270,14 @@ class QueueProcessor
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']++;
}
}
@@ -196,6 +292,344 @@ class QueueProcessor
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.
*
@@ -243,17 +677,29 @@ class QueueProcessor
{
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',
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
$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) {
@@ -287,68 +733,27 @@ class QueueProcessor
}
/**
* Simple DB-based lock to prevent concurrent queue processing.
* 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)");
// Use component params as lock storage
$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) ?: [];
$lockTime = $params['_queue_lock'] ?? 0;
// Lock expires after 120 seconds (safety valve for crashed processes)
if ($lockTime > 0 && (time() - $lockTime) < 120) {
return false;
}
$params['_queue_lock'] = time();
$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();
return true;
return (int) $db->loadResult() === 1;
}
/**
* Release the processing lock.
* Release the MySQL advisory lock.
*/
private static function releaseLock(): void
{
$db = Factory::getDbo();
$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['_queue_lock']);
$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->setQuery("SELECT RELEASE_LOCK('mokojoomcross_queue')");
$db->execute();
}
@@ -0,0 +1,96 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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<string, string>
*/
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 '<span class="' . $class . '" aria-hidden="true"></span>';
}
}
@@ -97,14 +97,17 @@ class DashboardModel extends BaseDatabaseModel
/**
* 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(): array
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',
@@ -118,6 +121,10 @@ class DashboardModel extends BaseDatabaseModel
->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() ?: [];
@@ -156,11 +163,12 @@ class DashboardModel extends BaseDatabaseModel
/**
* Get most cross-posted articles.
*
* @param int $limit Number of 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): array
public function getTopArticles(int $limit = 5, ?string $since = null): array
{
$db = $this->getDatabase();
@@ -177,6 +185,10 @@ class DashboardModel extends BaseDatabaseModel
->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() ?: [];
@@ -0,0 +1,83 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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;
}
}
}
@@ -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');
@@ -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);
}
}
@@ -0,0 +1,185 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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() ?: [];
}
}
@@ -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;
}
@@ -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;
}
}
@@ -13,8 +13,10 @@ 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
{
@@ -24,20 +26,40 @@ class HtmlView extends BaseHtmlView
protected $serviceBreakdown;
protected $dailyTrend;
protected $topArticles;
public $sidebar;
public $period;
public function display($tpl = null): void
{
$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();
$this->dailyTrend = $model->getDailyTrend(14);
$this->topArticles = $model->getTopArticles(5);
$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);
}
@@ -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)
);
}
}
@@ -0,0 +1,59 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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');
}
}
@@ -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)
);
}
}
@@ -0,0 +1,61 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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');
}
}
@@ -0,0 +1 @@
<\!DOCTYPE html><title></title>
@@ -0,0 +1,84 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\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)
);
}
}
@@ -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)
);
}
}
@@ -14,6 +14,8 @@ 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
@@ -42,5 +44,14 @@ class HtmlView extends BaseHtmlView
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)
);
}
}
@@ -14,6 +14,8 @@ 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
@@ -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('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)
);
}
}
@@ -14,6 +14,7 @@ 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;
@@ -30,6 +31,16 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
</div>
<?php endif; ?>
<?php if ($stats->queued_count > 50) : ?>
<div class="alert alert-warning d-flex align-items-start mb-3">
<span class="icon-exclamation-triangle me-2 mt-1" aria-hidden="true"></span>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE'); ?></strong><br>
<?php echo Text::sprintf('COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING', $stats->queued_count); ?>
</div>
</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-9">
<div class="row">
@@ -67,6 +78,71 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
</div>
</div>
<!-- Trend Chart -->
<?php if (!empty($this->dailyTrend)) : ?>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART'); ?></h5>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokojoomcross" />
<input type="hidden" name="view" value="dashboard" />
<select name="period" class="form-select form-select-sm" style="width: auto; display: inline-block;" onchange="this.form.submit();">
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_7_DAYS'); ?></option>
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_30_DAYS'); ?></option>
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_90_DAYS'); ?></option>
<option value="0" <?php echo $this->period == 0 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_ALL_TIME'); ?></option>
</select>
</form>
</div>
<div class="card-body">
<canvas id="trendChart" height="80"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-UPIssOjNMqMfON6mDKHvO4sOY4hhxN1ymYcfl2MrDz69idMU/L3MNFlyJGlIRjQH" crossorigin="anonymous">
<script>
document.addEventListener('DOMContentLoaded', function() {
var trendData = <?php echo json_encode($this->dailyTrend); ?>;
var labels = trendData.map(function(d) { return d.day; });
var posted = trendData.map(function(d) { return parseInt(d.posted, 10); });
var failed = trendData.map(function(d) { return parseInt(d.failed, 10); });
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_POSTED', true); ?>',
data: posted,
borderColor: '#198754',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
fill: true,
tension: 0.3
},
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_FAILED', true); ?>',
data: failed,
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
fill: true,
tension: 0.3
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
},
plugins: {
legend: { position: 'bottom' }
}
}
});
});
</script>
<?php endif; ?>
<?php if ($this->migrationAvailable) : ?>
<div class="alert alert-info">
<h4 class="alert-heading"><?php echo Text::_('COM_MOKOJOOMCROSS_MIGRATION_TITLE'); ?></h4>
@@ -102,7 +178,12 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
$rateClass = $rate >= 80 ? 'text-success' : ($rate >= 50 ? 'text-warning' : 'text-danger');
?>
<tr>
<td><?php echo htmlspecialchars($row['service_title'] . ' (' . ucfirst($row['service_type']) . ')'); ?></td>
<td>
<?php echo ServiceIconHelper::renderIcon($row['service_type']); ?>
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&view=servicestats&id=' . $row['service_id']); ?>">
<?php echo htmlspecialchars($row['service_title'] . ' (' . ucfirst($row['service_type']) . ')'); ?>
</a>
</td>
<td class="text-center"><span class="badge bg-success"><?php echo (int) $row['posted']; ?></span></td>
<td class="text-center"><span class="badge bg-danger"><?php echo (int) $row['failed']; ?></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><?php echo (int) $row['queued']; ?></span></td>
@@ -0,0 +1,79 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\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 action="<?php echo Route::_('index.php?option=com_mokojoomcross&layout=edit&id=' . $postId); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<div class="main-card">
<div class="row">
<div class="col-lg-8">
<h3><?php echo Text::_($isNew ? 'COM_MOKOJOOMCROSS_NEW_POST' : 'COM_MOKOJOOMCROSS_EDIT_POST'); ?></h3>
<?php if ($isNew) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_POST_CREATE_HELP'); ?>
</div>
<?php endif; ?>
<?php echo $this->form->renderFieldset('details'); ?>
</div>
<div class="col-lg-4">
<?php if (!$isNew) : ?>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-chart-bar" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_POST_RESULTS'); ?>
</h5>
</div>
<div class="card-body">
<?php echo $this->form->renderFieldset('readonly'); ?>
</div>
</div>
<?php
$status = $this->item->status ?? '';
if (in_array($status, ['failed', 'posted'])) : ?>
<div class="card mt-3">
<div class="card-body text-center">
<p class="text-muted mb-2"><?php echo Text::_('COM_MOKOJOOMCROSS_POST_REQUEUE_HELP'); ?></p>
<button type="button" class="btn btn-warning w-100"
onclick="document.getElementById('jform_status').value='queued'; Joomla.submitbutton('post.apply');">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_POST_REQUEUE'); ?>
</button>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -15,6 +15,7 @@ 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 */
@@ -93,11 +94,16 @@ $statusBadges = [
<?php endif; ?>
</td>
<td>
<?php echo $this->escape($item->article_title ?? 'Article #' . $item->article_id); ?>
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&task=post.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->article_title ?? 'Article #' . $item->article_id); ?>
</a>
<?php if (!empty($item->scheduled_at)) : ?>
<br><small class="text-info"><span class="icon-clock" aria-hidden="true"></span> <?php echo HTMLHelper::_('date', $item->scheduled_at, 'Y-m-d H:i'); ?></small>
<?php endif; ?>
</td>
<td>
<?php echo $this->escape($item->service_title ?? ''); ?>
<br><small class="text-muted"><?php echo $this->escape($item->service_type ?? ''); ?></small>
<br><small class="text-muted"><?php echo ServiceIconHelper::renderIcon($item->service_type ?? ''); ?> <?php echo $this->escape($item->service_type ?? ''); ?></small>
</td>
<td class="d-none d-md-table-cell">
<small><?php echo $this->escape(mb_substr($item->message ?? '', 0, 100)); ?></small>
@@ -0,0 +1,216 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\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] ?? '';
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomcross&layout=edit&id=' . $serviceId); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<div class="main-card">
<div class="row">
<div class="col-lg-8">
<h3><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICE_DETAILS'); ?></h3>
<?php echo $this->form->renderFieldset('details'); ?>
<hr>
<h3><?php echo Text::_('COM_MOKOJOOMCROSS_FIELDSET_CREDENTIALS'); ?></h3>
<p class="text-muted">
<?php echo Text::_('COM_MOKOJOOMCROSS_CREDENTIALS_HELP'); ?>
</p>
<?php echo $this->form->renderFieldset('credentials'); ?>
<?php if ($showAuthorize) : ?>
<div class="mt-3">
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&task=oauth.authorize&service_id=' . $serviceId); ?>"
class="btn btn-primary btn-lg">
<span class="icon-key" aria-hidden="true"></span>
<?php echo Text::sprintf('COM_MOKOJOOMCROSS_AUTHORIZE_BUTTON', ucfirst($serviceType)); ?>
</a>
<p class="form-text mt-2">
<?php echo Text::_('COM_MOKOJOOMCROSS_AUTHORIZE_HELP'); ?>
</p>
</div>
<?php endif; ?>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<span class="icon-question-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_HELP_TITLE'); ?>
</h5>
</div>
<div class="card-body">
<p><?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_HELP_INTRO'); ?></p>
<ol class="mb-0">
<li><?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_STEP1'); ?></li>
<li><?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_STEP2'); ?></li>
<li><?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_STEP3'); ?></li>
<li><?php echo Text::_('COM_MOKOJOOMCROSS_SETUP_STEP4'); ?></li>
</ol>
</div>
</div>
<?php if (!empty($helpAlias)) : ?>
<div class="card mt-3">
<div class="card-body text-center">
<a href="https://mokoconsulting.tech/kb/mokojoomcross/<?php echo $helpAlias; ?>"
target="_blank" rel="noopener"
class="btn btn-outline-info w-100">
<span class="icon-book" aria-hidden="true"></span>
<?php echo Text::sprintf('COM_MOKOJOOMCROSS_SERVICE_HELP_LINK', ucfirst($serviceType)); ?>
</a>
</div>
</div>
<?php endif; ?>
<?php if ($showAuthorize) : ?>
<div class="card mt-3">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">
<span class="icon-lock" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_OAUTH_HELP_TITLE'); ?>
</h5>
</div>
<div class="card-body">
<p><?php echo Text::_('COM_MOKOJOOMCROSS_OAUTH_HELP_BODY'); ?></p>
</div>
</div>
<?php endif; ?>
<?php if ($serviceId > 0 && !empty($serviceType)) : ?>
<div class="card mt-3">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<span class="icon-plug" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_TITLE'); ?>
</h5>
</div>
<div class="card-body">
<p><?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_DESC'); ?></p>
<button type="button" id="btn-test-connection" class="btn btn-outline-primary w-100">
<span class="icon-broadcast" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON'); ?>
</button>
<div id="test-connection-result" class="mt-2" style="display:none;"></div>
</div>
</div>
<script>
document.getElementById('btn-test-connection').addEventListener('click', function() {
var btn = this;
var resultDiv = document.getElementById('test-connection-result');
btn.disabled = true;
btn.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_TESTING'); ?>';
resultDiv.style.display = 'none';
var url = 'index.php?option=com_mokojoomcross&task=service.testConnection&id=<?php echo $serviceId; ?>&format=json';
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': Joomla.getOptions('csrf.token') || '1'
}
})
.then(function(response) { return response.json(); })
.then(function(json) {
resultDiv.style.display = 'block';
resultDiv.textContent = '';
if (json.success) {
var data = json.data || {};
var accountName = data.account_name || '';
var msg = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_SUCCESS'); ?>';
if (accountName) {
msg += ' \u2014 ' + accountName;
}
resultDiv.className = 'mt-2 alert alert-success';
resultDiv.textContent = msg;
} else {
resultDiv.className = 'mt-2 alert alert-danger';
resultDiv.textContent = json.message || '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_FAILED'); ?>';
}
})
.catch(function() {
resultDiv.style.display = 'block';
resultDiv.className = 'mt-2 alert alert-danger';
resultDiv.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_ERROR'); ?>';
})
.finally(function() {
btn.disabled = false;
btn.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON'); ?>';
});
});
</script>
<?php endif; ?>
</div>
</div>
</div>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1 @@
<\!DOCTYPE html><title></title>
@@ -15,6 +15,7 @@ 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 */
@@ -23,17 +24,6 @@ HTMLHelper::_('behavior.multiselect');
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
$serviceIcons = [
'facebook' => 'icon-facebook',
'twitter' => 'icon-twitter',
'linkedin' => 'icon-linkedin',
'mastodon' => 'icon-globe',
'bluesky' => 'icon-cloud',
'mailchimp' => 'icon-envelope',
'telegram' => 'icon-comment',
'discord' => 'icon-comments',
'slack' => 'icon-comments-2',
];
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomcross&view=services'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
@@ -75,7 +65,6 @@ $serviceIcons = [
<?php foreach ($this->items as $i => $item) :
$credentials = json_decode($item->credentials ?: '{}', true) ?: [];
$mode = $credentials['mode'] ?? 'custom';
$icon = $serviceIcons[$item->service_type] ?? 'icon-cog';
?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center">
@@ -90,7 +79,7 @@ $serviceIcons = [
</a>
</td>
<td>
<span class="<?php echo $icon; ?>" aria-hidden="true"></span>
<?php echo ServiceIconHelper::renderIcon($item->service_type); ?>
<?php echo $this->escape(ucfirst($item->service_type)); ?>
</td>
<td class="text-center d-none d-md-table-cell">
@@ -0,0 +1,219 @@
<?php
/**
* @package MokoJoomCross
* @subpackage com_mokojoomcross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\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 Header -->
<div class="d-flex align-items-center mb-4">
<?php echo ServiceIconHelper::renderIcon($service->service_type, 'fs-3 me-2'); ?>
<h2 class="mb-0"><?php echo $this->escape($service->title); ?></h2>
<span class="badge bg-secondary ms-2"><?php echo $this->escape(ucfirst($service->service_type)); ?></span>
</div>
<!-- Stats Cards -->
<div class="row">
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_TOTAL_POSTS'); ?></h5>
<p class="display-4"><?php echo $stats->total; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_POSTED'); ?></h5>
<p class="display-4 text-success"><?php echo $stats->posted; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_FAILED'); ?></h5>
<p class="display-4 text-danger"><?php echo $stats->failed; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_SUCCESS_RATE'); ?></h5>
<p class="display-4 <?php echo $rateClass; ?>"><?php echo $rate; ?>%</p>
</div>
</div>
</div>
</div>
<!-- Daily Trend Chart -->
<?php if (!empty($this->dailyTrend)) : ?>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART'); ?></h5>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokojoomcross" />
<input type="hidden" name="view" value="servicestats" />
<input type="hidden" name="id" value="<?php echo (int) $service->id; ?>" />
<select name="period" class="form-select form-select-sm" style="width: auto; display: inline-block;" onchange="this.form.submit();">
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_7_DAYS'); ?></option>
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_30_DAYS'); ?></option>
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_90_DAYS'); ?></option>
<option value="0" <?php echo $this->period == 0 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_ALL_TIME'); ?></option>
</select>
</form>
</div>
<div class="card-body">
<canvas id="serviceStatsChart" height="80"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-UPIssOjNMqMfON6mDKHvO4sOY4hhxN1ymYcfl2MrDz69idMU/L3MNFlyJGlIRjQH" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var trendData = <?php echo json_encode($this->dailyTrend); ?>;
var labels = trendData.map(function(d) { return d.day; });
var posted = trendData.map(function(d) { return parseInt(d.posted, 10); });
var failed = trendData.map(function(d) { return parseInt(d.failed, 10); });
new Chart(document.getElementById('serviceStatsChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_POSTED', true); ?>',
data: posted,
borderColor: '#198754',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
fill: true,
tension: 0.3
},
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_FAILED', true); ?>',
data: failed,
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
fill: true,
tension: 0.3
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
},
plugins: {
legend: { position: 'bottom' }
}
}
});
});
</script>
<?php endif; ?>
<!-- Recent Posts -->
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_RECENT_POSTS'); ?></h5>
</div>
<div class="card-body p-0">
<?php if (empty($this->recentPosts)) : ?>
<p class="p-3 mb-0 text-muted"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_NO_POSTS'); ?></p>
<?php else : ?>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_STATUS'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_ARTICLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_POSTED_AT'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_POST_ERROR'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->recentPosts as $post) :
$badgeClass = $statusBadges[$post['status']] ?? 'bg-secondary';
?>
<tr>
<td>
<span class="badge <?php echo $badgeClass; ?>">
<?php echo $this->escape(ucfirst($post['status'])); ?>
</span>
<?php if ((int) $post['retry_count'] > 0) : ?>
<br><small class="text-muted">Retries: <?php echo (int) $post['retry_count']; ?></small>
<?php endif; ?>
</td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&task=post.edit&id=' . (int) $post['id']); ?>">
<?php echo $this->escape($post['article_title'] ?? 'Article #' . $post['id']); ?>
</a>
</td>
<td>
<?php echo $post['posted_at'] ? HTMLHelper::_('date', $post['posted_at'], 'Y-m-d H:i') : '—'; ?>
</td>
<td>
<?php if (!empty($post['error_message'])) : ?>
<small class="text-danger"><?php echo $this->escape(mb_substr($post['error_message'], 0, 100)); ?></small>
<?php else : ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- Top Articles -->
<?php if (!empty($this->topArticles)) : ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_TOP_ARTICLES'); ?></h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<?php foreach ($this->topArticles as $row) : ?>
<div class="list-group-item d-flex justify-content-between align-items-center">
<span><?php echo htmlspecialchars($row['title']); ?></span>
<span>
<span class="badge bg-success"><?php echo (int) $row['success_count']; ?></span>
/
<span class="badge bg-secondary"><?php echo (int) $row['post_count']; ?></span>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
@@ -44,6 +44,9 @@ HTMLHelper::_('behavior.keepalive');
<tr><td><code>{category}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_CATEGORY'); ?></td></tr>
<tr><td><code>{author}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_AUTHOR'); ?></td></tr>
<tr><td><code>{date}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_DATE'); ?></td></tr>
<tr><td><code>{tags}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS'); ?></td></tr>
<tr><td><code>{hashtags}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS'); ?></td></tr>
<tr><td><code>{field:xxx}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_CUSTOM_FIELD'); ?></td></tr>
</tbody>
</table>
</div>
@@ -55,3 +58,57 @@ HTMLHelper::_('behavior.keepalive');
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
const platformLimits = {
twitter: 280, bluesky: 300, mastodon: 500, threads: 500,
telegram: 4096, discord: 2000, whatsapp: 4096,
linkedin: 3000, googlebusiness: 1500, matrix: 65536,
ntfy: 4096, facebook: 0, medium: 0, wordpress: 0,
ghost: 0, hashnode: 0, blogger: 0, devto: 0,
default: 0
};
const textarea = document.getElementById('jform_template_body');
const serviceSelect = document.getElementById('jform_service_type');
if (!textarea) {
return;
}
// Create counter element
const counter = document.createElement('div');
counter.id = 'mokojoomcross-char-counter';
counter.className = 'small mt-1';
textarea.parentNode.appendChild(counter);
function updateCounter() {
const len = textarea.value.length;
const serviceType = serviceSelect ? serviceSelect.value : 'default';
const limit = platformLimits[serviceType] || 0;
if (limit > 0) {
const ratio = len / limit;
let badgeClass = 'bg-success';
if (ratio > 1) {
badgeClass = 'bg-danger';
} else if (ratio > 0.9) {
badgeClass = 'bg-warning text-dark';
}
counter.innerHTML = '<span class="badge ' + badgeClass + '">Characters: ' + len + ' / ' + limit + '</span>';
} else {
counter.innerHTML = '<span class="badge bg-secondary">Characters: ' + len + ' (no limit)</span>';
}
}
textarea.addEventListener('input', updateCounter);
if (serviceSelect) {
serviceSelect.addEventListener('change', updateCounter);
}
// Initial count
updateCounter();
});
</script>
@@ -6,3 +6,8 @@ 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"
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoJoomCross</name>
<version>01.00.06-dev-dev</version>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -13,9 +13,14 @@ 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;
/**
@@ -30,6 +35,8 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
return [
'onContentBeforeDisplay' => 'onContentBeforeDisplay',
'onContentPrepareForm' => 'onContentPrepareForm',
'onContentAfterSave' => 'onContentAfterSave',
'onContentChangeState' => 'onContentChangeState',
];
}
@@ -40,8 +47,20 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
* - Checkbox list of all enabled services
* - Skip cross-posting toggle
*/
public function onContentPrepareForm(Form $form, $data): void
/**
* 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;
}
@@ -99,19 +118,109 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
showon="mokojoomcross_skip:0">
{$options}
</field>
<field
name="mokojoomcross_evergreen"
type="radio"
label="PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN"
description="PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_DESC"
default="0"
class="btn-group btn-group-yesno"
showon="mokojoomcross_skip:0">
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="mokojoomcross_evergreen_interval"
type="number"
label="PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL"
description="PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL_DESC"
default="30"
min="1"
max="365"
showon="mokojoomcross_skip:0[AND]mokojoomcross_evergreen:1"
/>
</fieldset>
</fields>
</form>
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 = '<div class="mokojoomcross-history">';
foreach ($history as $post) {
$badgeClass = match ($post->status) {
'posted' => 'bg-success',
'failed' => 'bg-danger',
'queued' => 'bg-warning',
default => 'bg-secondary',
};
$historyHtml .= '<div class="mb-1">'
. '<span class="badge ' . $badgeClass . ' me-1">' . ucfirst($post->status) . '</span>'
. '<small>' . htmlspecialchars($post->service_title ?? '') . '</small>';
if ($post->posted_at) {
$historyHtml .= ' <small class="text-muted">' . HTMLHelper::_('date', $post->posted_at, 'Y-m-d H:i') . '</small>';
}
if ($post->status === 'failed' && $post->error_message) {
$historyHtml .= '<br><small class="text-danger">' . htmlspecialchars(mb_substr($post->error_message, 0, 60)) . '</small>';
}
$historyHtml .= '</div>';
}
$historyHtml .= '</div>';
// 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 = '<?xml version="1.0"?>
<form><fields name="attribs"><fieldset name="mokojoomcross">
<field name="mokojoomcross_history" type="note"
label="PLG_CONTENT_MOKOJOOMCROSS_HISTORY"
description="" />
</fieldset></fields></form>';
$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(string $context, &$article, &$params, int $page = 0): string
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 '';
}
@@ -153,4 +262,100 @@ XML;
return '<div class="mokojoomcross-status mb-2">' . $badges . '</div>';
}
/**
* 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');
}
}
}
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>MokoJoomCross - ActivityPub (Fediverse)</name>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoJoomCross\Activitypub</namespace>
<files>
<filename plugin="activitypub">activitypub.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_activitypub.ini</language>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_activitypub.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_ACTIVITYPUB="MokoJoomCross - ActivityPub (Fediverse)"
PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION="Cross-post Joomla articles to ActivityPub (Fediverse)."
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_ACTIVITYPUB="MokoJoomCross - ActivityPub (Fediverse)"
PLG_MOKOJOOMCROSS_ACTIVITYPUB_DESCRIPTION="Cross-post Joomla articles to ActivityPub (Fediverse)."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_activitypub
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\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;
}
);
}
};
@@ -0,0 +1,112 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_activitypub
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Plugin\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'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>MokoJoomCross - Google Blogger</name>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoJoomCross\Blogger</namespace>
<files>
<filename plugin="blogger">blogger.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_blogger.ini</language>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_blogger.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_BLOGGER="MokoJoomCross - Google Blogger"
PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION="Cross-post Joomla articles to Google Blogger."
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_BLOGGER="MokoJoomCross - Google Blogger"
PLG_MOKOJOOMCROSS_BLOGGER_DESCRIPTION="Cross-post Joomla articles to Google Blogger."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_blogger
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\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;
}
);
}
};
@@ -0,0 +1,115 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_blogger
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Plugin\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'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>MokoJoomCross - Bluesky</name>
<version>01.00.06-dev-dev</version>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -127,4 +127,9 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoJoomC
return json_decode($response, true) ?: [];
}
public function getSupportedMediaTypes(): array
{
return ['image'];
}
}
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>MokoJoomCross - Brevo (Sendinblue)</name>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoJoomCross\Brevo</namespace>
<files>
<filename plugin="brevo">brevo.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_brevo.ini</language>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_brevo.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_BREVO="MokoJoomCross - Brevo (Sendinblue)"
PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION="Cross-post Joomla articles to Brevo (Sendinblue)."
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_BREVO="MokoJoomCross - Brevo (Sendinblue)"
PLG_MOKOJOOMCROSS_BREVO_DESCRIPTION="Cross-post Joomla articles to Brevo (Sendinblue)."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_brevo
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\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;
}
);
}
};
@@ -0,0 +1,92 @@
<?php
/**
* @package MokoJoomCross
* @subpackage plg_mokojoomcross_brevo
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Plugin\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'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>MokoJoomCross - Constant Contact</name>
<version>01.00.06-dev</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoJoomCross\Constantcontact</namespace>
<files>
<filename plugin="constantcontact">constantcontact.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_constantcontact.ini</language>
<language tag="en-GB">language/en-GB/plg_mokojoomcross_constantcontact.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_CONSTANTCONTACT="MokoJoomCross - Constant Contact"
PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION="Cross-post Joomla articles to Constant Contact."
@@ -0,0 +1,2 @@
PLG_MOKOJOOMCROSS_CONSTANTCONTACT="MokoJoomCross - Constant Contact"
PLG_MOKOJOOMCROSS_CONSTANTCONTACT_DESCRIPTION="Cross-post Joomla articles to Constant Contact."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>

Some files were not shown because too many files have changed in this diff Show More