Compare commits

..

42 Commits

Author SHA1 Message Date
gitea-actions[bot] c552a12a0e chore(version): pre-release bump to 01.11.02-dev [skip ci] 2026-06-28 18:47:14 +00:00
jmiller 133944620b Merge pull request 'fix: Joomla 6 event type compatibility in content plugin' (#207) from fix/joomla6-event-handlers into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
2026-06-28 18:46:11 +00:00
gitea-actions[bot] ed5a143439 chore(version): pre-release bump to 01.11.01-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
2026-06-28 18:45:54 +00:00
jmiller c1fa8c816e fix: use typed Joomla 6 event parameters, remove legacy fallbacks
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Joomla 6 dispatches Model\AfterSaveEvent and Model\ContentChangeStateEvent
instead of Content\AfterSaveEvent and Content\ContentChangeStateEvent.
Remove specific type hints to accept both Joomla 5 and 6 event objects.

Authored-by: Moko Consulting
2026-06-28 13:45:17 -05:00
gitea-actions[bot] acef5eb3a3 chore(version): pre-release bump to 01.08.61-dev [skip ci] 2026-06-28 17:10:30 +00:00
jmiller 5743915447 Merge pull request 'chore: clean up CHANGELOG formatting and add missing features' (#204) from fix/changelog-cleanup into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
2026-06-28 17:10:10 +00:00
jmiller 9905d1e634 chore: clean up CHANGELOG formatting and add missing features
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 42s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- Fix duplicate section headers for 01.07.00, 01.05.00, 01.04.01
- Move format description and version comment to top of file
- Replace em-dash with ASCII double-dash (Gitea UTF-8 compat)
- Add missing entries: link shortening (#159), site frontend (#133),
  social preview (#156), PHPUnit test suite (#132)

Authored-by: Moko Consulting
2026-06-28 12:08:38 -05:00
gitea-actions[bot] 47594c963d chore(version): pre-release bump to 01.08.58-dev [skip ci] 2026-06-28 17:05:20 +00:00
jmiller 5621542141 Merge pull request 'fix: analytics MVC model, PreviewController IDOR, README/CHANGELOG updates' (#202) from fix/changelog-security-readme into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 17s
2026-06-28 17:05:04 +00:00
gitea-actions[bot] ee581032c3 chore(version): pre-release bump to 01.08.57-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
2026-06-28 17:04:22 +00:00
jmiller dd4de77202 fix: add missing calendar/analytics submenu entries and language strings
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 20s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- Add 'calendar' and 'analytics' entries to MokoSuiteCrossHelper submenu
- Add COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH/NEXT_MONTH/TODAY strings
- Add COM_MOKOSUITECROSS_SUBMENU_CALENDAR string

Authored-by: Moko Consulting
2026-06-28 12:02:51 -05:00
jmiller 3d567353c9 fix: PreviewController IDOR + update README and CHANGELOG
- Add ACL check and parameterized query to PreviewController
- Filter articles by user view access levels
- Update README with new features (AI captions, social preview, OG image
  gen, link shortening, post calendar, analytics)
- Update Nostr status from stub to implemented
- Add security fix entry to CHANGELOG

Authored-by: Moko Consulting
2026-06-28 12:02:24 -05:00
gitea-actions[bot] 8e7381e3ea chore(version): pre-release bump to 01.08.56-dev [skip ci] 2026-06-28 16:58:10 +00:00
jmiller 3eb56f738e Merge pull request 'feat: best time to post analytics view (#165)' (#200) from feature/165-analytics into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
2026-06-28 16:57:56 +00:00
gitea-actions[bot] dba61e3e0c chore(version): auto-bump patch 01.08.55-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-28 16:57:47 +00:00
jmiller 9c2dd1bdde feat(#165): add posting analytics with best-time heatmap
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 36s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- AnalyticsHelper: posting heatmap (7x24 grid), best times ranking,
  per-service breakdown with success rates
- AnalyticsController: AJAX endpoint for dynamic heatmap filtering
- Analytics HtmlView: toolbar, dashboard link, submenu integration
- Template: heatmap table with color intensity, best times cards,
  service breakdown table, service/period filters
- 16 new language strings for analytics UI

Authored-by: Moko Consulting
Closes #165
2026-06-28 11:57:02 -05:00
gitea-actions[bot] ee49fbbaa6 chore(version): pre-release bump to 01.08.54-dev [skip ci] 2026-06-28 16:50:13 +00:00
jmiller 4785a1f5b4 Merge pull request 'feat: visual post calendar admin view (#160)' (#198) from feature/160-visual-calendar into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
2026-06-28 16:50:03 +00:00
jmiller b6202a6a40 feat: add visual post calendar admin view (#160)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 28s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Add a monthly calendar grid view to the admin component showing
scheduled, queued, and posted cross-posts with color-coded status
badges. Includes month-by-month navigation and today highlighting.

New files:
- CalendarController, CalendarModel, Calendar HtmlView, calendar template

Modified files:
- MokoSuiteCrossHelper: added Calendar to submenu
- Language file: added calendar strings
- CHANGELOG.md: documented new feature

Authored-by: Moko Consulting
2026-06-28 11:49:23 -05:00
gitea-actions[bot] 0c2074f801 chore(version): pre-release bump to 01.08.53-dev [skip ci] 2026-06-28 16:45:59 +00:00
jmiller e27b958712 Merge pull request 'feat: social image generator with GD-based OG images (#157)' (#199) from feature/157-social-image-generator into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
2026-06-28 16:45:47 +00:00
gitea-actions[bot] a169ea4967 chore(version): auto-bump patch 01.08.52-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-28 16:43:40 +00:00
jmiller d951d86b3a feat: add social image generator with PHP GD for OG images (#157)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- SocialImageHelper: generates 1200x630 OG images with title overlay
- SocialImageController: AJAX endpoint to generate from article data
- Config fields: bg color, text color, overlay style, site name override
- Supports background image scaling, dark/light overlay, TTF fonts

Closes #157
Authored-by: Moko Consulting
2026-06-28 11:43:09 -05:00
gitea-actions[bot] b03c7c6ba7 chore(version): pre-release bump to 01.08.51-dev [skip ci] 2026-06-28 16:29:19 +00:00
jmiller 1c15497c32 Merge pull request 'fix: prevent GitHub Actions injection in CI issue reporter' (#197) from fix/ci-workflow-injection into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
2026-06-28 16:29:10 +00:00
gitea-actions[bot] 9e38609fe9 chore(version): pre-release bump to 01.08.50-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
2026-06-28 16:29:10 +00:00
jmiller b907b778c0 fix: pass workflow inputs via env block to prevent injection
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 10s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 27s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 11:26:44 -05:00
gitea-actions[bot] 4d758890a8 chore(version): pre-release bump to 01.08.49-dev [skip ci] 2026-06-28 16:25:20 +00:00
jmiller 824b4d9ecd Merge pull request 'feat: TikTok video upload and photo carousel (#164)' (#196) from feature/164-tiktok-enhancements into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
2026-06-28 16:25:09 +00:00
jmiller 307eb7741d feat: add TikTok video upload and photo carousel support (#164)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 29s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- Video publishing via PULL_FROM_URL with async status polling
- Photo carousel up to 35 images via content/init endpoint
- Configurable posting mode: DIRECT_POST or MEDIA_UPLOAD
- Audit warning language string for unverified app limitations
- Updated getSupportedMediaTypes() to include carousel

Authored-by: Moko Consulting
2026-06-28 11:24:23 -05:00
gitea-actions[bot] 4a13ea6ade chore(version): pre-release bump to 01.08.47-dev [skip ci] 2026-06-28 16:23:21 +00:00
jmiller bcc17e4882 Merge pull request 'feat: AI caption generation with Claude/OpenAI (#161)' (#195) from feature/161-ai-post-generation into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-06-28 16:23:13 +00:00
gitea-actions[bot] 4ce96dc95b chore(version): auto-bump patch 01.08.46-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-28 16:22:43 +00:00
jmiller 99e4a83ed5 feat: add AI caption generation with Claude and OpenAI support (#161)
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
- AiGeneratorHelper: Claude Messages API and OpenAI Chat Completions
  with structured JSON output for social/short/chat/email_subject
- AiController: AJAX endpoint with CSRF and ACL checks
- config.xml: new AI fieldset (provider, API key, model, tone)
- Content plugin: "Generate with AI" button in Share Content panel
- Language strings for all AI config and UI elements

Authored-by: Moko Consulting
2026-06-28 11:21:56 -05:00
gitea-actions[bot] 63c4fbcd14 chore(version): pre-release bump to 01.08.45-dev [skip ci] 2026-06-28 16:15:30 +00:00
jmiller 15a03b309b Merge pull request 'feat(#133): Site frontend with cross-post list and detail views' (#187) from feature/133-site-frontend into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-06-28 16:15:20 +00:00
jmiller a537132836 feat(#133): add site frontend with cross-post list and detail views
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 30s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 11:14:58 -05:00
gitea-actions[bot] 6f29c077e2 chore(version): pre-release bump to 01.08.44-dev [skip ci] 2026-06-28 16:14:39 +00:00
jmiller 9fa2560ce4 Merge pull request 'feat(#159): Link shortening (Bitly, Rebrandly, YOURLS)' (#186) from feature/159-link-shortening into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
2026-06-28 16:14:27 +00:00
Jonathan Miller 45afb1f0b1 feat(#159): add link shortening support (Bitly, Rebrandly, YOURLS) with {url_short} placeholder
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 33s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 11:14:04 -05:00
gitea-actions[bot] 843c729828 chore(version): pre-release bump to 01.08.43-dev [skip ci] 2026-06-28 16:12:35 +00:00
jmiller db061e2b75 Merge pull request 'feat(#132): PHPUnit test suite' (#185) from feature/132-phpunit-tests into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
2026-06-28 16:12:18 +00:00
102 changed files with 2620 additions and 85 deletions
+72 -1
View File
@@ -1 +1,72 @@
IyBDb3B5cmlnaHQgKEMpIDIwMjYgTW9rbyBDb25zdWx0aW5nIDxoZWxsb0Btb2tvY29uc3VsdGluZy50ZWNoPgojCiMgU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEdQTC0zLjAtb3ItbGF0ZXIKIwojIEZJTEUgSU5GT1JNQVRJT04KIyBERUZHUk9VUDogR2l0ZWEuV29ya2Zsb3cKIyBJTkdST1VQOiBtb2tvY2xpLlVuaXZlcnNhbAojIFJFUE86IGh0dHBzOi8vZ2l0Lm1va29jb25zdWx0aW5nLnRlY2gvTW9rb0NvbnN1bHRpbmcvbW9rb2NsaQojIFBBVEg6IC8ubW9rb2dpdGVhL3dvcmtmbG93cy9jaS1pc3N1ZS1yZXBvcnRlci55bWwKIyBWRVJTSU9OOiAwMS4wMC4wMAojIEJSSUVGOiBSZXVzYWJsZSB3b3JrZmxvdyDigJQgY3JlYXRlcy91cGRhdGVzIGEgR2l0ZWEgaXNzdWUgd2hlbiBhIENJIGdhdGUgZmFpbHMuCiMgICAgICAgIENsb25lcyBNb2tvQ0xJIGFuZCBydW5zIGNsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaC4KCm5hbWU6ICJVbml2ZXJzYWw6IENJIElzc3VlIFJlcG9ydGVyIgoKb246CiAgd29ya2Zsb3dfY2FsbDoKICAgIGlucHV0czoKICAgICAgZ2F0ZToKICAgICAgICBkZXNjcmlwdGlvbjogIkNJIGdhdGUgbmFtZSAoZS5nLiBQUiBWYWxpZGF0aW9uLCBSZXBvc2l0b3J5IEhlYWx0aCkiCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgZGV0YWlsczoKICAgICAgICBkZXNjcmlwdGlvbjogIkh1bWFuLXJlYWRhYmxlIGZhaWx1cmUgZGVzY3JpcHRpb24iCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgc2V2ZXJpdHk6CiAgICAgICAgZGVzY3JpcHRpb246ICJlcnJvciBvciB3YXJuaW5nIgogICAgICAgIHJlcXVpcmVkOiBmYWxzZQogICAgICAgIHR5cGU6IHN0cmluZwogICAgICAgIGRlZmF1bHQ6ICJlcnJvciIKICAgICAgd29ya2Zsb3c6CiAgICAgICAgZGVzY3JpcHRpb246ICJXb3JrZmxvdyBuYW1lIGZvciB0aGUgaXNzdWUgdGl0bGUiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgICAgZGVmYXVsdDogIiIKICAgIHNlY3JldHM6CiAgICAgIE1PS09HSVRFQV9UT0tFTjoKICAgICAgICByZXF1aXJlZDogdHJ1ZQoKZW52OgogIEZPUkNFX0pBVkFTQ1JJUFRfQUNUSU9OU19UT19OT0RFMjQ6IHRydWUKCmpvYnM6CiAgcmVwb3J0OgogICAgbmFtZTogIlJlcG9ydDogJHt7IGlucHV0cy5nYXRlIH19IgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAoKICAgIHN0ZXBzOgogICAgICAtIG5hbWU6IENsb25lIE1va29DTEkKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgTU9LT0dJVEVBX1VSTD0iJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fSIKICAgICAgICAgIGdpdCBjbG9uZSAtLWRlcHRoIDEgLS1maWx0ZXI9YmxvYjpub25lIC0tc3BhcnNlICIke01PS09HSVRFQV9VUkx9L01va29Db25zdWx0aW5nL01va29DTEkuZ2l0IiAvdG1wL21va29jbGkKICAgICAgICAgIGNkIC90bXAvbW9rb2NsaSAmJiBnaXQgc3BhcnNlLWNoZWNrb3V0IHNldCBjbGkvY2lfaXNzdWVfcmVwb3J0ZXIuc2gKCiAgICAgIC0gbmFtZTogUmVwb3J0IENJIGZhaWx1cmUKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgICAgTU9LT0dJVEVBX1VSTDogJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgY2htb2QgK3ggL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaAogICAgICAgICAgL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaCBcCiAgICAgICAgICAgIC0tZ2F0ZSAiJHt7IGlucHV0cy5nYXRlIH19IiBcCiAgICAgICAgICAgIC0tZGV0YWlscyAiJHt7IGlucHV0cy5kZXRhaWxzIH19IiBcCiAgICAgICAgICAgIC0tc2V2ZXJpdHkgIiR7eyBpbnB1dHMuc2V2ZXJpdHkgfX0iIFwKICAgICAgICAgICAgLS13b3JrZmxvdyAiJHt7IGlucHV0cy53b3JrZmxvdyB9fSIK # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
on:
workflow_call:
inputs:
gate:
description: "CI gate name (e.g. PR Validation, Repository Health)"
required: true
type: string
details:
description: "Human-readable failure description"
required: true
type: string
severity:
description: "error or warning"
required: false
type: string
default: "error"
workflow:
description: "Workflow name for the issue title"
required: false
type: string
default: ""
secrets:
MOKOGITEA_TOKEN:
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
report:
name: "Report: ${{ inputs.gate }}"
runs-on: ubuntu-latest
steps:
- name: Clone MokoCLI
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
- name: Report CI failure
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
INPUT_GATE: ${{ inputs.gate }}
INPUT_DETAILS: ${{ inputs.details }}
INPUT_SEVERITY: ${{ inputs.severity }}
INPUT_WORKFLOW: ${{ inputs.workflow }}
run: |
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
/tmp/mokocli/cli/ci_issue_reporter.sh \
--gate "$INPUT_GATE" \
--details "$INPUT_DETAILS" \
--severity "$INPUT_SEVERITY" \
--workflow "$INPUT_WORKFLOW"
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 01.08.42 # VERSION: 01.11.02
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+21 -1
View File
@@ -2,6 +2,17 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Visual post calendar**: Monthly calendar grid view showing scheduled, queued, and posted cross-posts with status badges (#160)
- **Calendar navigation**: Month-by-month navigation with today highlighting (#160)
- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165)
- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range
- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day
- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload
- **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157)
- **Social image config**: Background color, text color, overlay style, and site name override in component options (#157)
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings
- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields
- **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries - **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries
- **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20) - **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20)
- **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts - **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts
@@ -21,8 +32,17 @@
- **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162) - **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162)
- **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162) - **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162)
- **Facebook draft posts**: Save feed posts as unpublished drafts (#162) - **Facebook draft posts**: Save feed posts as unpublished drafts (#162)
- **TikTok video upload**: PULL_FROM_URL video publishing via video/init endpoint with status polling (#164)
- **TikTok photo carousel**: Up to 35 image carousel posts via content/init endpoint (#164)
- **TikTok posting mode**: Configurable DIRECT_POST or MEDIA_UPLOAD (sends to TikTok inbox for in-app editing) (#164)
- **TikTok audit warning**: Language string explaining that unverified apps can only create private posts (#164)
- **Link shortening**: Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder (#159)
- **Site frontend**: Public-facing cross-post list and detail views for site visitors (#133)
- **Social preview**: AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in article editor (#156)
- **PHPUnit test suite**: Unit tests for models, helpers, and service plugins (#132)
### Fixed ### Fixed
- **PreviewController**: Add ACL check and parameterized query to prevent unauthorized article access (IDOR)
- Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` - Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()`
## [01.07.00] --- 2026-06-23 ## [01.07.00] --- 2026-06-23
@@ -89,7 +109,7 @@
## [01.03.00] --- 2026-06-21 ## [01.03.00] --- 2026-06-21
<!-- VERSION: 01.08.42 --> <!-- VERSION: 01.11.02 -->
All notable changes to MokoSuiteCross will be documented in this file. All notable changes to MokoSuiteCross will be documented in this file.
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Template-Joomla DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/ REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.08.42 VERSION: 01.11.02
PATH: ./CODE_OF_CONDUCT.md PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1 NOTE: Adapted with attribution from the Contributor Covenant v2.1
+8 -2
View File
@@ -1,6 +1,6 @@
# MokoSuiteCross # MokoSuiteCross
<!-- VERSION: 01.08.42 --> <!-- VERSION: 01.11.02 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6. Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
@@ -16,8 +16,14 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
- **Post queue** — Scheduled posting, retry on failure, detailed delivery logs - **Post queue** — Scheduled posting, retry on failure, detailed delivery logs
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx}) - **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx})
- **Share Content panel** — Per-article fields for platform-optimized text (social, short, chat, email) with image picker - **Share Content panel** — Per-article fields for platform-optimized text (social, short, chat, email) with image picker
- **AI caption generation** — Generate platform-optimized captions using Claude or OpenAI with one click
- **Social preview** — AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in the article editor
- **Social image generator** — Generate Open Graph images with article title overlay using PHP GD
- **Link shortening** — Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder
- **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares - **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares
- **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token - **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token
- **Post calendar** — Visual monthly calendar view of scheduled and completed cross-posts
- **Posting analytics** — Best time to post heatmap with per-service breakdown and recommendations
- **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms) - **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms)
- **Post history** — Track what was posted where, with platform response data - **Post history** — Track what was posted where, with platform response data
- **Evergreen re-sharing** — Automatically re-share articles on a configurable interval - **Evergreen re-sharing** — Automatically re-share articles on a configurable interval
@@ -82,7 +88,7 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
| RSS Feed | `plg_mokosuitecross_rssfeed` | Implemented | | RSS Feed | `plg_mokosuitecross_rssfeed` | Implemented |
| ActivityPub | `plg_mokosuitecross_activitypub` | Implemented | | ActivityPub | `plg_mokosuitecross_activitypub` | Implemented |
| Google Business | `plg_mokosuitecross_googlebusiness` | Implemented | | Google Business | `plg_mokosuitecross_googlebusiness` | Implemented |
| Nostr | `plg_mokosuitecross_nostr` | Stub (WebSocket deferred) | | Nostr | `plg_mokosuitecross_nostr` | Implemented |
## Installation ## Installation
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 01.08.42 VERSION: 01.11.02
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
@@ -120,6 +120,42 @@
/> />
</fieldset> </fieldset>
<fieldset name="link_shortening" label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING">
<field
name="link_shortener"
type="list"
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER"
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC"
default="none">
<option value="none">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE</option>
<option value="bitly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY</option>
<option value="rebrandly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY</option>
<option value="yourls">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS</option>
</field>
<field
name="link_shortener_api_key"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY"
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC"
showon="link_shortener:bitly,rebrandly"
/>
<field
name="link_shortener_yourls_url"
type="url"
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL"
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC"
hint="https://short.example.com/yourls-api.php"
showon="link_shortener:yourls"
/>
<field
name="link_shortener_yourls_token"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN"
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC"
showon="link_shortener:yourls"
/>
</fieldset>
<fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN"> <fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
<field <field
name="evergreen_enabled" name="evergreen_enabled"
@@ -191,6 +227,95 @@
/> />
</fieldset> </fieldset>
<fieldset name="ai" label="COM_MOKOSUITECROSS_CONFIG_AI">
<field
name="ai_provider"
type="list"
label="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER"
description="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC"
default="none">
<option value="none">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE</option>
<option value="claude">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE</option>
<option value="openai">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI</option>
</field>
<field
name="ai_api_key"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY"
description="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC"
showon="ai_provider:claude,openai"
/>
<field
name="ai_model"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_AI_MODEL"
description="COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC"
hint="claude-haiku-4-5 / gpt-4o-mini"
showon="ai_provider:claude,openai"
/>
<field
name="ai_tone"
type="list"
label="COM_MOKOSUITECROSS_CONFIG_AI_TONE"
description="COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC"
default="professional"
showon="ai_provider:claude,openai">
<option value="professional">COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL</option>
<option value="friendly">COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY</option>
<option value="casual">COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL</option>
</field>
</fieldset>
<fieldset name="social_image" label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE">
<field
name="social_image_enabled"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC"
default="0"
class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="social_image_bg_color"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
default="#1a1a2e"
showon="social_image_enabled:1"
/>
<field
name="social_image_text_color"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
default="#ffffff"
showon="social_image_enabled:1"
/>
<field
name="social_image_font_size"
type="number"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC"
default="48"
min="24"
max="96"
showon="social_image_enabled:1"
/>
<field
name="social_image_show_site_name"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC"
default="1"
class="btn-group"
showon="social_image_enabled:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES"> <fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
<field <field
name="category_rules_note" name="category_rules_note"
@@ -534,7 +534,76 @@ COM_MOKOSUITECROSS_DISPATCH_INVALID_SERVICES="service_ids must be a non-empty ar
COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found." COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found."
COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request." COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request."
; Link Shortening
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING="Link Shortening"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER="Link Shortener"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC="Select a link shortening service. Shortened URLs are available via the {url_short} placeholder in templates."
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE="None (disabled)"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY="Bitly"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY="Rebrandly"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS="YOURLS (self-hosted)"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY="API Key"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC="API key for Bitly or Rebrandly."
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL="YOURLS API URL"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC="Full URL to your YOURLS API endpoint (e.g. https://short.example.com/yourls-api.php)."
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN="YOURLS Signature Token"
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC="Secret signature token from your YOURLS installation."
; AI Caption Generation
COM_MOKOSUITECROSS_CONFIG_AI="AI Caption Generation"
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER="AI Provider"
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC="Select an AI provider to generate cross-post captions from article content. The API key is stored in Joomla component params (encrypted at rest)."
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE="None (disabled)"
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE="Anthropic Claude"
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI="OpenAI"
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY="API Key"
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC="API key for the selected AI provider."
COM_MOKOSUITECROSS_CONFIG_AI_MODEL="Model"
COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC="AI model to use. Leave blank for the default (Claude Haiku 4.5 or GPT-4o Mini)."
COM_MOKOSUITECROSS_CONFIG_AI_TONE="Tone"
COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC="The writing tone for generated captions."
COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL="Professional"
COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY="Friendly"
COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL="Casual"
COM_MOKOSUITECROSS_AI_GENERATE="Generate with AI"
COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from the article content using AI."
COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..."
COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully."
COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s"
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
; Analytics
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Time Period"
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER="Service"
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
COM_MOKOSUITECROSS_ANALYTICS_HOURLY="Hourly Distribution"
COM_MOKOSUITECROSS_ANALYTICS_DAILY="Day of Week Distribution"
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough posting data to generate recommendations. Post at least 3 times per time slot over the selected period."
COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS="%d of %d successful"
COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN="Sun"
COM_MOKOSUITECROSS_ANALYTICS_DAY_MON="Mon"
COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE="Tue"
COM_MOKOSUITECROSS_ANALYTICS_DAY_WED="Wed"
COM_MOKOSUITECROSS_ANALYTICS_DAY_THU="Thu"
COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI="Fri"
COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT="Sat"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH="High success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM="Medium success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW="Low success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data"
COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days"
COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days"
; Category Rules ; Category Rules
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules" COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing" COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release." COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release."
; Calendar View
COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH="Previous"
COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH="Next"
COM_MOKOSUITECROSS_CALENDAR_TODAY="Today"
COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Post Calendar"
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>com_mokosuitecross</name> <name>com_mokosuitecross</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,5 +1,14 @@
; MokoSuiteCross Site Frontend Language File ; MokoSuiteCross -- Site Frontend Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved. ; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later ; License: GPL-3.0-or-later
COM_MOKOSUITECROSS="MokoSuiteCross" COM_MOKOSUITECROSS="MokoSuiteCross"
COM_MOKOSUITECROSS_POSTS_LIST_TITLE="Cross-Posted Content"
COM_MOKOSUITECROSS_POST_DETAIL_TITLE="Cross-Post History"
COM_MOKOSUITECROSS_COLUMN_ARTICLE="Article"
COM_MOKOSUITECROSS_COLUMN_PLATFORMS="Platforms"
COM_MOKOSUITECROSS_COLUMN_LAST_POSTED="Last Posted"
COM_MOKOSUITECROSS_COLUMN_STATUS="Status"
COM_MOKOSUITECROSS_COLUMN_POSTED_DATE="Posted Date"
COM_MOKOSUITECROSS_COLUMN_LINK="Platform Link"
COM_MOKOSUITECROSS_NO_POSTS="No cross-posted content found."
@@ -17,5 +17,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController class DisplayController extends BaseController
{ {
protected $default_view = 'post'; protected $default_view = 'posts';
} }
@@ -0,0 +1,64 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class PostModel extends BaseDatabaseModel
{
public function getArticle(int $articleId): ?object
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$query = $db->getQuery(true)
->select('a.id, a.title, a.alias, a.catid, a.access')
->from($db->quoteName('#__content', 'a'))
->where('a.id = ' . (int) $articleId)
->where('a.state = 1');
$groups = $user->getAuthorisedViewLevels();
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
$db->setQuery($query);
return $db->loadObject() ?: null;
}
public function getPosts(int $articleId): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
'p.id',
'p.status',
'p.platform_post_id',
'p.posted_at',
'p.error_message',
'p.created',
's.title AS service_title',
's.service_type',
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
->where('p.article_id = ' . (int) $articleId)
->order('p.created DESC');
$db->setQuery($query, 0, 50);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,51 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\ListModel;
class PostsModel extends ListModel
{
protected function getListQuery()
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$query = $db->getQuery(true);
$query->select([
'a.id AS article_id',
'a.title AS article_title',
'a.alias AS article_alias',
'a.catid',
'MAX(p.posted_at) AS last_posted',
'COUNT(p.id) AS post_count',
'GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR \',\') AS service_types',
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__content', 'a') . ' ON a.id = p.article_id')
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
->where('p.status = ' . $db->quote('posted'))
->where('a.state = 1');
// Access filtering
$groups = $user->getAuthorisedViewLevels();
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
$query->group('a.id, a.title, a.alias, a.catid')
->order('last_posted DESC');
return $query;
}
}
@@ -0,0 +1,33 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Site\View\Post;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
class HtmlView extends BaseHtmlView
{
protected $article;
protected $posts;
public function display($tpl = null): void
{
$articleId = Factory::getApplication()->getInput()->getInt('id', 0);
$model = $this->getModel();
$this->article = $model->getArticle($articleId);
$this->posts = $model->getPosts($articleId);
parent::display($tpl);
}
}
@@ -0,0 +1,30 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Site\View\Posts;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
parent::display($tpl);
}
}
@@ -0,0 +1,84 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
$statusClasses = [
'posted' => 'bg-success',
'failed' => 'bg-danger',
'permanently_failed' => 'bg-danger',
'queued' => 'bg-warning text-dark',
'posting' => 'bg-info',
'scheduled' => 'bg-primary',
'deleted' => 'bg-secondary',
'cancelled' => 'bg-secondary',
];
?>
<div class="com-mokosuitecross-post">
<?php if (!$this->article) : ?>
<div class="alert alert-warning">
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
</div>
<?php else : ?>
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POST_DETAIL_TITLE'); ?></h2>
<p>
<strong><?php echo $this->escape($this->article->title); ?></strong>
</p>
<?php if (empty($this->posts)) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
</div>
<?php else : ?>
<table class="table table-striped">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOSUITECROSS_HEADING_SERVICE'); ?></th>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_STATUS'); ?></th>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_POSTED_DATE'); ?></th>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LINK'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->posts as $post) : ?>
<tr>
<td>
<span class="badge bg-secondary"><?php echo $this->escape($post->service_type); ?></span>
<?php echo $this->escape($post->service_title); ?>
</td>
<td>
<span class="badge <?php echo $statusClasses[$post->status] ?? 'bg-secondary'; ?>">
<?php echo $this->escape(ucfirst($post->status)); ?>
</span>
</td>
<td><?php echo $post->posted_at ? $this->escape($post->posted_at) : $this->escape($post->created); ?></td>
<td>
<?php if (!empty($post->platform_post_id)) : ?>
<span class="text-muted small"><?php echo $this->escape($post->platform_post_id); ?></span>
<?php else : ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=posts'); ?>" class="btn btn-secondary">
&larr; <?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?>
</a>
<?php endif; ?>
</div>
@@ -0,0 +1,66 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
?>
<div class="com-mokosuitecross-posts">
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?></h2>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
</div>
<?php else : ?>
<table class="table table-striped">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_ARTICLE'); ?></th>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_PLATFORMS'); ?></th>
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LAST_POSTED'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $item) : ?>
<tr>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=post&id=' . (int) $item->article_id); ?>">
<?php echo $this->escape($item->article_title); ?>
</a>
</td>
<td>
<?php
$types = explode(',', $item->service_types ?? '');
foreach ($types as $type) :
$type = trim($type);
if (empty($type)) continue;
?>
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
<?php endforeach; ?>
</td>
<td>
<?php echo $item->last_posted ? $this->escape($item->last_posted) : '—'; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($this->pagination->pagesTotal > 1) : ?>
<div class="com-mokosuitecross-posts__pagination">
<?php echo $this->pagination->getListFooter(); ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -1 +0,0 @@
/* 01.08.42 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.43 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.44 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.45 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.46 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.47 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.49 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.50 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.51 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.52 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.53 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.54 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.55 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.56 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.57 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.58 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.61 — no schema changes */
@@ -0,0 +1 @@
/* 01.11.01 — no schema changes */
@@ -0,0 +1 @@
/* 01.11.02 — no schema changes */
@@ -0,0 +1,100 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AiGeneratorHelper;
class AiController extends BaseController
{
public function generate(): void
{
if (!Session::checkToken('get')) {
echo json_encode(['success' => false, 'error' => 'Invalid token']);
$this->app->close();
return;
}
$user = $this->app->getIdentity();
if (!$user->authorise('core.edit', 'com_mokosuitecross')) {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
$this->app->close();
return;
}
$articleId = $this->input->getInt('article_id', 0);
if ($articleId < 1) {
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
$this->app->close();
return;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'introtext', 'catid']))
->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . $articleId);
$db->setQuery($query);
$article = $db->loadObject();
if (!$article) {
echo json_encode(['success' => false, 'error' => 'Article not found']);
$this->app->close();
return;
}
$category = '';
$catQuery = $db->getQuery(true)
->select($db->quoteName('title'))
->from($db->quoteName('#__categories'))
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
$db->setQuery($catQuery);
$category = $db->loadResult() ?: '';
$tagQuery = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.content_item_id') . ' = ' . $articleId)
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'));
$db->setQuery($tagQuery);
$tags = $db->loadColumn() ?: [];
$introtext = strip_tags($article->introtext ?? '');
$introtext = mb_substr($introtext, 0, 500);
$params = \Joomla\CMS\Component\ComponentHelper::getParams('com_mokosuitecross');
$config = [
'ai_provider' => $params->get('ai_provider', 'none'),
'ai_api_key' => $params->get('ai_api_key', ''),
'ai_model' => $params->get('ai_model', ''),
'ai_tone' => $params->get('ai_tone', 'professional'),
];
$result = AiGeneratorHelper::generate($article->title, $introtext, $category, $tags, $config);
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo json_encode($result);
$this->app->close();
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class AnalyticsController extends BaseController
{
public function display($cachable = false, $urlparams = []): static
{
return parent::display($cachable, $urlparams);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class CalendarController extends BaseController
{
public function display($cachable = false, $urlparams = []): static
{
return parent::display($cachable, $urlparams);
}
}
@@ -31,6 +31,17 @@ class PreviewController extends BaseController
return; return;
} }
$user = $this->app->getIdentity();
if (!$user->authorise('core.manage', 'com_mokosuitecross')
&& !$user->authorise('core.edit', 'com_content')
&& !$user->authorise('core.edit.own', 'com_content')) {
echo json_encode(['error' => 'Permission denied']);
$this->app->close();
return;
}
$articleId = $this->input->getInt('article_id', 0); $articleId = $this->input->getInt('article_id', 0);
$platform = $this->input->getCmd('platform', 'twitter'); $platform = $this->input->getCmd('platform', 'twitter');
@@ -43,10 +54,14 @@ class PreviewController extends BaseController
$db = Factory::getDbo(); $db = Factory::getDbo();
$groups = $user->getAuthorisedViewLevels();
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select('*') ->select('*')
->from($db->quoteName('#__content')) ->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . $articleId); ->where($db->quoteName('id') . ' = :id')
->where($db->quoteName('access') . ' IN (' . implode(',', array_map('intval', $groups)) . ')')
->bind(':id', $articleId, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query); $db->setQuery($query);
$article = $db->loadObject(); $article = $db->loadObject();
@@ -0,0 +1,98 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\SocialImageHelper;
class SocialImageController extends BaseController
{
public function generate(): void
{
if (!Session::checkToken('get')) {
echo json_encode(['success' => false, 'error' => 'Invalid token']);
$this->app->close();
return;
}
$user = $this->app->getIdentity();
if (!$user->authorise('core.manage', 'com_mokosuitecross')) {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
$this->app->close();
return;
}
$articleId = $this->input->getInt('article_id', 0);
if ($articleId < 1) {
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
$this->app->close();
return;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'images']))
->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . $articleId);
$db->setQuery($query);
$article = $db->loadObject();
if (!$article) {
echo json_encode(['success' => false, 'error' => 'Article not found']);
$this->app->close();
return;
}
$params = ComponentHelper::getParams('com_mokosuitecross');
$siteName = $params->get('social_image_site_name', '') ?: Factory::getApplication()->get('sitename', '');
$options = [
'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
'text_color' => $params->get('social_image_text_color', '#ffffff'),
'overlay' => $params->get('social_image_overlay', 'dark'),
];
$backgroundPath = null;
$images = json_decode($article->images ?? '{}', true);
if (!empty($images['image_intro'])) {
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_intro'], '/');
} elseif (!empty($images['image_fulltext'])) {
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_fulltext'], '/');
}
try {
$imagePath = SocialImageHelper::generate($article->title, $siteName, $backgroundPath, $options);
$imageUrl = str_replace(JPATH_ROOT, Uri::root(true), str_replace('\\', '/', $imagePath));
$result = ['success' => true, 'image_url' => $imageUrl, 'image_path' => $imagePath];
} catch (\Throwable $e) {
$result = ['success' => false, 'error' => $e->getMessage()];
}
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo json_encode($result);
$this->app->close();
}
}
@@ -0,0 +1,196 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
class AiGeneratorHelper
{
public static function generate(string $title, string $introtext, string $category, array $tags, array $config): array
{
$provider = $config['ai_provider'] ?? 'none';
$apiKey = $config['ai_api_key'] ?? '';
$model = $config['ai_model'] ?? '';
$tone = $config['ai_tone'] ?? 'professional';
if ($provider === 'none' || $apiKey === '') {
return ['success' => false, 'error' => 'AI provider not configured or API key missing.'];
}
$prompt = self::buildPrompt($title, $introtext, $category, $tags, $tone);
$response = match ($provider) {
'claude' => self::callClaude($prompt, $apiKey, $model ?: 'claude-haiku-4-5'),
'openai' => self::callOpenAI($prompt, $apiKey, $model ?: 'gpt-4o-mini'),
default => '',
};
if ($response === '') {
return ['success' => false, 'error' => 'AI provider returned an empty response.'];
}
$parsed = self::parseResponse($response);
if ($parsed === null) {
return ['success' => false, 'error' => 'Could not parse AI response as JSON.'];
}
return ['success' => true, 'data' => $parsed];
}
private static function callClaude(string $prompt, string $apiKey, string $model): string
{
$payload = json_encode([
'model' => $model,
'max_tokens' => 500,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01',
],
]);
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return '';
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
return '';
}
$data = json_decode($response, true);
return $data['content'][0]['text'] ?? '';
}
private static function callOpenAI(string $prompt, string $apiKey, string $model): string
{
$payload = json_encode([
'model' => $model,
'max_tokens' => 500,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return '';
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
return '';
}
$data = json_decode($response, true);
return $data['choices'][0]['message']['content'] ?? '';
}
private static function buildPrompt(string $title, string $introtext, string $category, array $tags, string $tone): string
{
$tagList = !empty($tags) ? implode(', ', $tags) : 'none';
$toneGuide = match ($tone) {
'casual' => 'Use a relaxed, conversational tone.',
'friendly' => 'Use a warm, approachable tone with enthusiasm.',
default => 'Use a professional, polished tone.',
};
return <<<PROMPT
Generate cross-post captions for this article. {$toneGuide}
Article title: {$title}
Content summary: {$introtext}
Category: {$category}
Tags: {$tagList}
Return ONLY a JSON object with these keys (no markdown, no explanation):
{
"social": "Facebook/LinkedIn post (max 200 chars, include a call to action)",
"short": "Twitter/Bluesky post (max 270 chars, punchy, include 1-2 relevant hashtags)",
"chat": "Telegram/Discord message (max 300 chars, conversational)",
"email_subject": "Email subject line (max 60 chars, compelling, no clickbait)"
}
Rules:
- Do not include the article URL (it is added automatically)
- Do not wrap the JSON in markdown code fences
- Respect the character limits strictly
- Each caption should be unique, not just a reformatted version of the others
PROMPT;
}
private static function parseResponse(string $response): ?array
{
$response = trim($response);
if (preg_match('/\{[\s\S]*\}/', $response, $matches)) {
$response = $matches[0];
}
$data = json_decode($response, true);
if (!\is_array($data)) {
return null;
}
$required = ['social', 'short', 'chat', 'email_subject'];
foreach ($required as $key) {
if (!isset($data[$key]) || !\is_string($data[$key])) {
return null;
}
}
return [
'social' => mb_substr($data['social'], 0, 500),
'short' => mb_substr($data['short'], 0, 280),
'chat' => mb_substr($data['chat'], 0, 500),
'email_subject' => mb_substr($data['email_subject'], 0, 120),
];
}
}
@@ -0,0 +1,160 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class AnalyticsHelper
{
private static array $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
public static function getPostingHeatmap(string $serviceType = '', int $days = 90): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS dow')
->select('HOUR(' . $db->quoteName('p.posted_at') . ') AS hr')
->select('COUNT(*) AS cnt')
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
->where($db->quoteName('p.posted_at') . ' IS NOT NULL');
if ($days > 0) {
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
$query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($since));
}
if ($serviceType !== '') {
$query->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType));
}
$query->group('dow, hr')
->order('dow ASC, hr ASC');
$db->setQuery($query);
$rows = $db->loadObjectList();
$grid = [];
for ($d = 0; $d < 7; $d++) {
$grid[$d] = array_fill(0, 24, 0);
}
foreach ($rows as $row) {
$grid[(int) $row->dow][(int) $row->hr] = (int) $row->cnt;
}
return $grid;
}
public static function getBestTimes(string $serviceType = '', int $days = 90, int $limit = 5): array
{
$grid = self::getPostingHeatmap($serviceType, $days);
$slots = [];
foreach ($grid as $dow => $hours) {
foreach ($hours as $hour => $count) {
if ($count > 0) {
$slots[] = [
'day' => self::$dayNames[$dow],
'hour' => $hour,
'count' => $count,
'label' => self::$dayNames[$dow] . ' ' . self::formatHour($hour),
];
}
}
}
usort($slots, static fn($a, $b) => $b['count'] <=> $a['count']);
return \array_slice($slots, 0, $limit);
}
public static function getServiceBreakdown(int $days = 30): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('s.service_type'))
->select($db->quoteName('s.title', 'service_title'))
->select('COUNT(*) AS total')
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success')
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed')
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'));
if ($days > 0) {
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since));
}
$query->group($db->quoteName(['s.service_type', 's.title']))
->order('total DESC');
$db->setQuery($query);
$rows = $db->loadObjectList();
$result = [];
foreach ($rows as $row) {
$total = (int) $row->total;
$success = (int) $row->success;
$result[] = [
'service_type' => $row->service_type,
'service_title' => $row->service_title,
'total' => $total,
'success' => $success,
'failed' => (int) $row->failed,
'success_rate' => $total > 0 ? round(($success / $total) * 100, 1) : 0.0,
'avg_per_day' => $days > 0 ? round($total / $days, 1) : 0.0,
];
}
return $result;
}
public static function getServiceTypes(): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('service_type'))
->from($db->quoteName('#__mokosuitecross_services'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('service_type') . ' ASC');
$db->setQuery($query);
return $db->loadColumn() ?: [];
}
private static function formatHour(int $hour): string
{
if ($hour === 0) {
return '12:00 AM';
}
if ($hour < 12) {
return $hour . ':00 AM';
}
if ($hour === 12) {
return '12:00 PM';
}
return ($hour - 12) . ':00 PM';
}
}
@@ -477,12 +477,16 @@ class CrossPostDispatcher
$url = $url . $separator . http_build_query($utmParams); $url = $url . $separator . http_build_query($utmParams);
} }
// Link shortening (#159) — shorten the final URL (with UTM if enabled)
$urlShort = LinkShortenerHelper::shorten($url);
return [ return [
'{title}' => $titleText, '{title}' => $titleText,
'{introtext}' => $introStripped, '{introtext}' => $introStripped,
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)), '{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
'{url}' => $url, '{url}' => $url,
'{url_raw}' => $urlRaw, '{url_raw}' => $urlRaw,
'{url_short}' => $urlShort,
'{image}' => $introImage, '{image}' => $introImage,
'{category}' => $categoryName, '{category}' => $categoryName,
'{author}' => $authorName, '{author}' => $authorName,
@@ -0,0 +1,172 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
/**
* Shortens URLs via Bitly, Rebrandly, or YOURLS.
*
* Returns the original URL on any failure so cross-posts are never broken.
*/
class LinkShortenerHelper
{
/**
* Shorten a URL using the configured provider.
*
* @param string $url The URL to shorten
*
* @return string Shortened URL, or the original on failure/disabled
*/
public static function shorten(string $url): string
{
$params = ComponentHelper::getParams('com_mokosuitecross');
$provider = $params->get('link_shortener', 'none');
if ($provider === 'none' || empty($url)) {
return $url;
}
$apiKey = $params->get('link_shortener_api_key', '');
switch ($provider) {
case 'bitly':
return self::shortenWithBitly($url, $apiKey);
case 'rebrandly':
return self::shortenWithRebrandly($url, $apiKey);
case 'yourls':
$apiUrl = $params->get('link_shortener_yourls_url', '');
$token = $params->get('link_shortener_yourls_token', '');
return self::shortenWithYourls($url, $apiUrl, $token);
default:
return $url;
}
}
/**
* Shorten via Bitly API v4.
*/
public static function shortenWithBitly(string $url, string $apiKey): string
{
if (empty($apiKey)) {
return $url;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api-ssl.bitly.com/v4/shorten',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['long_url' => $url]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
return $url;
}
$data = json_decode($response, true);
return $data['link'] ?? $url;
}
/**
* Shorten via Rebrandly API.
*/
public static function shortenWithRebrandly(string $url, string $apiKey, string $workspace = ''): string
{
if (empty($apiKey)) {
return $url;
}
$headers = [
'apikey: ' . $apiKey,
'Content-Type: application/json',
];
if (!empty($workspace)) {
$headers[] = 'workspace: ' . $workspace;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.rebrandly.com/v1/links',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['destination' => $url]),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
return $url;
}
$data = json_decode($response, true);
$short = $data['shortUrl'] ?? '';
return !empty($short) ? 'https://' . ltrim($short, 'https://') : $url;
}
/**
* Shorten via YOURLS API (self-hosted).
*/
public static function shortenWithYourls(string $url, string $apiUrl, string $signatureToken): string
{
if (empty($apiUrl) || empty($signatureToken)) {
return $url;
}
$endpoint = rtrim($apiUrl, '/') . '?' . http_build_query([
'action' => 'shorturl',
'format' => 'json',
'signature' => $signatureToken,
'url' => $url,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
return $url;
}
$data = json_decode($response, true);
return $data['shorturl'] ?? $url;
}
}
@@ -41,6 +41,8 @@ class MokoSuiteCrossHelper
'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES', 'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES',
'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES', 'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES',
'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS',
'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR',
'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS',
]; ];
// Joomla 5+ toolbar submenu // Joomla 5+ toolbar submenu
@@ -0,0 +1,207 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
class SocialImageHelper
{
private const WIDTH = 1200;
private const HEIGHT = 630;
/**
* Generate a branded social/OG image with text overlay.
*
* @param string $title Article title to render on the image
* @param string $siteName Site name for branding watermark
* @param array $config Rendering config: bg_color, text_color, font_size, show_site_name
*
* @return array ['success' => bool, 'image_url' => string, 'error' => string]
*/
public static function generate(string $title, string $siteName, array $config): array
{
if (!\function_exists('imagecreatetruecolor')) {
return ['success' => false, 'error' => 'PHP GD extension is not available'];
}
$bgColor = $config['bg_color'] ?? '#1a1a2e';
$textColor = $config['text_color'] ?? '#ffffff';
$fontSize = (int) ($config['font_size'] ?? 48);
$showSiteName = (bool) ($config['show_site_name'] ?? true);
$fontSize = max(24, min(96, $fontSize));
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
if ($image === false) {
return ['success' => false, 'error' => 'Failed to create image canvas'];
}
$bgRgb = self::hexToRgb($bgColor);
$textRgb = self::hexToRgb($textColor);
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
$text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
$fontFile = self::findFont();
if ($fontFile !== null) {
self::renderTtfText($image, $title, $text, $fontSize, $fontFile);
if ($showSiteName && $siteName !== '') {
$siteSize = (int) round($fontSize * 0.45);
$siteBox = imagettfbbox($siteSize, 0, $fontFile, $siteName);
$siteX = self::WIDTH - ($siteBox[2] - $siteBox[0]) - 40;
$siteY = self::HEIGHT - 30;
imagettftext($image, $siteSize, 0, $siteX, $siteY, $text, $fontFile, $siteName);
}
} else {
self::renderFallbackText($image, $title, $text);
if ($showSiteName && $siteName !== '') {
$siteX = self::WIDTH - (\strlen($siteName) * imagefontwidth(3)) - 40;
$siteY = self::HEIGHT - 30;
imagestring($image, 3, $siteX, $siteY, $siteName, $text);
}
}
$outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
$hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
$filename = $hash . '.png';
$filePath = $outputDir . '/' . $filename;
if (!imagepng($image, $filePath, 6)) {
imagedestroy($image);
return ['success' => false, 'error' => 'Failed to save image file'];
}
imagedestroy($image);
$imageUrl = 'media/com_mokosuitecross/social/' . $filename;
return ['success' => true, 'image_url' => $imageUrl];
}
private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
{
$maxWidth = self::WIDTH - 120;
$lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
$lineHeight = (int) round($fontSize * 1.4);
$totalHeight = \count($lines) * $lineHeight;
$startY = (int) round((self::HEIGHT - $totalHeight) / 2) + $fontSize;
foreach ($lines as $i => $line) {
$y = $startY + ($i * $lineHeight);
imagettftext($image, $fontSize, 0, 60, $y, $color, $fontFile, $line);
}
}
private static function renderFallbackText(\GdImage $image, string $title, int $color): void
{
$font = 5;
$charWidth = imagefontwidth($font);
$charHeight = imagefontheight($font);
$maxChars = (int) floor((self::WIDTH - 120) / $charWidth);
$lines = wordwrap($title, $maxChars, "\n", true);
$lineArray = explode("\n", $lines);
$lineHeight = $charHeight + 8;
$totalHeight = \count($lineArray) * $lineHeight;
$startY = (int) round((self::HEIGHT - $totalHeight) / 2);
foreach ($lineArray as $i => $line) {
$y = $startY + ($i * $lineHeight);
imagestring($image, $font, 60, $y, $line, $color);
}
}
/**
* Word-wrap text for TTF rendering at a given pixel width.
*
* @return string[]
*/
private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
{
$words = explode(' ', $text);
$lines = [];
$currentLine = '';
foreach ($words as $word) {
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
$box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
$width = abs($box[2] - $box[0]);
if ($width > $maxWidth && $currentLine !== '') {
$lines[] = $currentLine;
$currentLine = $word;
} else {
$currentLine = $testLine;
}
}
if ($currentLine !== '') {
$lines[] = $currentLine;
}
return $lines ?: [$text];
}
/**
* Locate a usable TTF font file -- check common system locations.
*/
private static function findFont(): ?string
{
$candidates = [
JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf',
JPATH_ROOT . '/media/com_mokosuitecross/fonts/Roboto-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
'C:/Windows/Fonts/arial.ttf',
'C:/Windows/Fonts/segoeui.ttf',
];
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* @return int[] [r, g, b]
*/
private static function hexToRgb(string $hex): array
{
$hex = ltrim($hex, '#');
if (\strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
return [
(int) hexdec(substr($hex, 0, 2)),
(int) hexdec(substr($hex, 2, 2)),
(int) hexdec(substr($hex, 4, 2)),
];
}
}
@@ -0,0 +1,169 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class AnalyticsModel extends BaseDatabaseModel
{
public function getHeatmap(int $days = 90, ?int $serviceId = null): array
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
$query = $db->getQuery(true)
->select([
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
])
->from($db->quoteName('#__mokosuitecross_posts'))
->where($db->quoteName('posted_at') . ' IS NOT NULL')
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')')
->order('dow ASC, hour_of_day ASC');
if ($serviceId !== null && $serviceId > 0) {
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
}
$db->setQuery($query);
$rows = $db->loadAssocList() ?: [];
$grid = [];
for ($d = 1; $d <= 7; $d++) {
for ($h = 0; $h < 24; $h++) {
$grid[$d][$h] = ['total' => 0, 'success' => 0, 'rate' => 0];
}
}
foreach ($rows as $row) {
$d = (int) $row['dow'];
$h = (int) $row['hour_of_day'];
$grid[$d][$h] = [
'total' => (int) $row['total'],
'success' => (int) $row['success'],
'rate' => (int) $row['total'] > 0
? round(((int) $row['success'] / (int) $row['total']) * 100)
: 0,
];
}
return $grid;
}
public function getBestTimes(int $days = 90, ?int $serviceId = null, int $limit = 5): array
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
$query = $db->getQuery(true)
->select([
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
])
->from($db->quoteName('#__mokosuitecross_posts'))
->where($db->quoteName('posted_at') . ' IS NOT NULL')
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')')
->having('COUNT(*) >= 3')
->order('success DESC, total DESC');
if ($serviceId !== null && $serviceId > 0) {
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
}
$db->setQuery($query, 0, $limit);
return $db->loadAssocList() ?: [];
}
public function getHourlyDistribution(int $days = 90, ?int $serviceId = null): array
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
$query = $db->getQuery(true)
->select([
'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day',
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed',
])
->from($db->quoteName('#__mokosuitecross_posts'))
->where($db->quoteName('posted_at') . ' IS NOT NULL')
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
->group('HOUR(' . $db->quoteName('posted_at') . ')')
->order('hour_of_day ASC');
if ($serviceId !== null && $serviceId > 0) {
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
}
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
public function getDayOfWeekDistribution(int $days = 90, ?int $serviceId = null): array
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s');
$query = $db->getQuery(true)
->select([
'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow',
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed',
])
->from($db->quoteName('#__mokosuitecross_posts'))
->where($db->quoteName('posted_at') . ' IS NOT NULL')
->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff))
->group('DAYOFWEEK(' . $db->quoteName('posted_at') . ')')
->order('dow ASC');
if ($serviceId !== null && $serviceId > 0) {
$query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId);
}
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
public function getServices(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title'), $db->quoteName('service_type')])
->from($db->quoteName('#__mokosuitecross_services'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('title') . ' ASC');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
}
@@ -0,0 +1,67 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class CalendarModel extends BaseDatabaseModel
{
/**
* Get cross-post events for a given month, grouped by date.
*
* @param int $year Four-digit year
* @param int $month Month number (1-12)
*
* @return array Associative array keyed by Y-m-d, each value an array of event objects
*/
public function getEvents(int $year, int $month): array
{
$db = $this->getDatabase();
$firstDay = sprintf('%04d-%02d-01', $year, $month);
$lastDay = date('Y-m-t', strtotime($firstDay));
$dateExpr = 'COALESCE('
. $db->quoteName('p.scheduled_at') . ', '
. $db->quoteName('p.posted_at') . ', '
. $db->quoteName('p.created') . ')';
$query = $db->getQuery(true)
->select([
'DATE(' . $dateExpr . ') AS event_date',
$db->quoteName('p.status'),
$db->quoteName('s.service_type'),
$db->quoteName('c.title', 'article_title'),
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('LEFT', $db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->join('LEFT', $db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id'))
->where('DATE(' . $dateExpr . ') >= ' . $db->quote($firstDay))
->where('DATE(' . $dateExpr . ') <= ' . $db->quote($lastDay))
->order('DATE(' . $dateExpr . ') ASC, ' . $db->quoteName('p.created') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$grouped = [];
foreach ($rows as $row) {
$grouped[$row->event_date][] = $row;
}
return $grouped;
}
}
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Analytics;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
class HtmlView extends BaseHtmlView
{
public $heatmap;
public $bestTimes;
public $hourlyDistribution;
public $dayDistribution;
public $services;
public $serviceId;
public $period;
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoSuiteCross\Administrator\Model\AnalyticsModel $model */
$model = $this->getModel();
$input = Factory::getApplication()->input;
$this->period = $input->getInt('period', 90);
$this->serviceId = $input->getInt('service_id', 0);
$validPeriods = [7, 30, 90, 180, 365];
if (!\in_array($this->period, $validPeriods, true)) {
$this->period = 90;
}
$sid = $this->serviceId > 0 ? $this->serviceId : null;
$this->heatmap = $model->getHeatmap($this->period, $sid);
$this->bestTimes = $model->getBestTimes($this->period, $sid);
$this->hourlyDistribution = $model->getHourlyDistribution($this->period, $sid);
$this->dayDistribution = $model->getDayOfWeekDistribution($this->period, $sid);
$this->services = $model->getServices();
$this->addToolbar();
MokoSuiteCrossHelper::addSubmenu('analytics');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('MokoSuiteCross -- Analytics', 'chart');
}
}
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Calendar;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
class HtmlView extends BaseHtmlView
{
public int $year;
public int $month;
public array $events;
public $sidebar;
public function display($tpl = null): void
{
$input = Factory::getApplication()->input;
$this->year = $input->getInt('year', (int) date('Y'));
$this->month = $input->getInt('month', (int) date('n'));
if ($this->month < 1 || $this->month > 12) {
$this->month = (int) date('n');
}
if ($this->year < 2000 || $this->year > 2100) {
$this->year = (int) date('Y');
}
$model = $this->getModel();
$this->events = $model->getEvents($this->year, $this->month);
$this->addToolbar();
MokoSuiteCrossHelper::addSubmenu('calendar');
$this->sidebar = \Joomla\CMS\HTML\Sidebar::render();
parent::display($tpl);
}
protected function addToolbar(): void
{
$canDo = MokoSuiteCrossHelper::getActions();
ToolbarHelper::title('MokoSuiteCross -- Post Calendar', 'calendar');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitecross&view=dashboard');
if ($canDo->get('core.admin')) {
ToolbarHelper::preferences('com_mokosuitecross');
}
}
}
@@ -0,0 +1,240 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Analytics\HtmlView $this */
$dayNames = [
1 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN'),
2 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_MON'),
3 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE'),
4 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_WED'),
5 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_THU'),
6 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI'),
7 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT'),
];
?>
<form method="get" class="mb-3">
<input type="hidden" name="option" value="com_mokosuitecross" />
<input type="hidden" name="view" value="analytics" />
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label" for="analytics-period"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_PERIOD'); ?></label>
<select name="period" id="analytics-period" class="form-select" onchange="this.form.submit();">
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_7_DAYS'); ?></option>
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_30_DAYS'); ?></option>
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_90_DAYS'); ?></option>
<option value="180" <?php echo $this->period == 180 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_180_DAYS'); ?></option>
<option value="365" <?php echo $this->period == 365 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_365_DAYS'); ?></option>
</select>
</div>
<div class="col-auto">
<label class="form-label" for="analytics-service"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER'); ?></label>
<select name="service_id" id="analytics-service" class="form-select" onchange="this.form.submit();">
<option value="0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES'); ?></option>
<?php foreach ($this->services as $svc) : ?>
<option value="<?php echo (int) $svc['id']; ?>" <?php echo $this->serviceId == $svc['id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($svc['title'] . ' (' . ucfirst($svc['service_type']) . ')'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
</form>
<?php if (!empty($this->bestTimes)) : ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h5>
</div>
<div class="card-body">
<div class="row">
<?php foreach ($this->bestTimes as $bt) :
$rate = (int) $bt['total'] > 0 ? round(((int) $bt['success'] / (int) $bt['total']) * 100) : 0;
?>
<div class="col-sm-6 col-md-4 col-lg mb-2">
<div class="border rounded p-3 text-center h-100">
<div class="fw-bold text-primary"><?php echo $dayNames[(int) $bt['dow']]; ?></div>
<div class="display-6"><?php echo sprintf('%02d:00', (int) $bt['hour_of_day']); ?></div>
<div class="text-muted small">
<?php echo Text::sprintf('COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS', (int) $bt['success'], (int) $bt['total']); ?>
</div>
<span class="badge bg-success"><?php echo $rate; ?>%</span>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php else : ?>
<div class="alert alert-info mb-3">
<?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?>
</div>
<?php endif; ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HEATMAP'); ?></h5>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="table table-sm table-bordered text-center mb-0" style="min-width: 700px;">
<thead>
<tr>
<th></th>
<?php for ($h = 0; $h < 24; $h++) : ?>
<th class="small" style="width: 3.8%;"><?php echo sprintf('%02d', $h); ?></th>
<?php endfor; ?>
</tr>
</thead>
<tbody>
<?php
$maxTotal = 1;
foreach ($this->heatmap as $dayData) {
foreach ($dayData as $cell) {
if ($cell['total'] > $maxTotal) {
$maxTotal = $cell['total'];
}
}
}
foreach ($this->heatmap as $dow => $hours) : ?>
<tr>
<th class="text-nowrap small"><?php echo $dayNames[$dow]; ?></th>
<?php foreach ($hours as $hour => $cell) :
$intensity = $maxTotal > 0 ? $cell['total'] / $maxTotal : 0;
$r = 255;
$g = 255;
$b = 255;
if ($cell['total'] > 0) {
$rate = $cell['rate'];
if ($rate >= 80) {
$r = (int) (255 - (155 * $intensity));
$g = (int) (255 - (100 * $intensity));
$b = (int) (255 - (155 * $intensity));
} elseif ($rate >= 50) {
$r = (int) (255 - (50 * $intensity));
$g = (int) (255 - (50 * $intensity));
$b = (int) (255 - (200 * $intensity));
} else {
$r = (int) (255 - (35 * $intensity));
$g = (int) (255 - (200 * $intensity));
$b = (int) (255 - (200 * $intensity));
}
}
?>
<td style="background: rgb(<?php echo "$r,$g,$b"; ?>); cursor: default;"
title="<?php echo $dayNames[$dow] . ' ' . sprintf('%02d:00', $hour) . ': ' . $cell['total'] . ' posts, ' . $cell['success'] . ' success (' . $cell['rate'] . '%)'; ?>">
<?php if ($cell['total'] > 0) : ?>
<small><?php echo $cell['total']; ?></small>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="d-flex justify-content-center gap-3 mt-2 small text-muted">
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(100,155,100); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(205,205,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(220,55,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(255,255,255); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE'); ?></span>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HOURLY'); ?></h5>
</div>
<div class="card-body">
<canvas id="hourlyChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAILY'); ?></h5>
</div>
<div class="card-body">
<canvas id="dayChart" height="200"></canvas>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-UPIssOjNMqMfON6mDKHvO4sOY4hhxN1ymYcfl2MrDz69idMU/L3MNFlyJGlIRjQH" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var hourlyData = <?php echo json_encode(array_values($this->hourlyDistribution)); ?>;
var hourLabels = [];
var hourSuccess = [];
var hourFailed = [];
for (var h = 0; h < 24; h++) {
hourLabels.push(('0' + h).slice(-2) + ':00');
var found = hourlyData.find(function(d) { return parseInt(d.hour_of_day, 10) === h; });
hourSuccess.push(found ? parseInt(found.success, 10) : 0);
hourFailed.push(found ? parseInt(found.failed, 10) : 0);
}
new Chart(document.getElementById('hourlyChart'), {
type: 'bar',
data: {
labels: hourLabels,
datasets: [
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_POSTED', true); ?>', data: hourSuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_FAILED', true); ?>', data: hourFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
]
},
options: {
responsive: true,
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
var dayData = <?php echo json_encode(array_values($this->dayDistribution)); ?>;
var dayLabels = <?php echo json_encode(array_values($dayNames)); ?>;
var daySuccess = [];
var dayFailed = [];
for (var d = 1; d <= 7; d++) {
var found = dayData.find(function(r) { return parseInt(r.dow, 10) === d; });
daySuccess.push(found ? parseInt(found.success, 10) : 0);
dayFailed.push(found ? parseInt(found.failed, 10) : 0);
}
new Chart(document.getElementById('dayChart'), {
type: 'bar',
data: {
labels: dayLabels,
datasets: [
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_POSTED', true); ?>', data: daySuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
{ label: '<?php echo Text::_('COM_MOKOSUITECROSS_DASHBOARD_FAILED', true); ?>', data: dayFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
]
},
options: {
responsive: true,
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
});
</script>
@@ -0,0 +1,129 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
$year = $this->year;
$month = $this->month;
$events = $this->events;
$today = date('Y-m-d');
$prevMonth = $month - 1;
$prevYear = $year;
if ($prevMonth < 1) {
$prevMonth = 12;
$prevYear--;
}
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) {
$nextMonth = 1;
$nextYear++;
}
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
$daysInMonth = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
$firstWeekday = ((int) date('N', mktime(0, 0, 0, $month, 1, $year))) - 1;
$statusClass = static function (string $status): string {
return match ($status) {
'posted' => 'bg-success',
'failed' => 'bg-danger',
default => 'bg-warning text-dark',
};
};
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $prevYear . '&month=' . $prevMonth); ?>"
class="btn btn-outline-secondary btn-sm">
<span class="icon-chevron-left" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH'); ?>
</a>
<h3 class="mb-0"><?php echo htmlspecialchars($monthName . ' ' . $year); ?></h3>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $nextYear . '&month=' . $nextMonth); ?>"
class="btn btn-outline-secondary btn-sm">
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH'); ?>
<span class="icon-chevron-right" aria-hidden="true"></span>
</a>
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead class="table-light">
<tr>
<th style="width:14.28%"><?php echo Text::_('MON'); ?></th>
<th style="width:14.28%"><?php echo Text::_('TUE'); ?></th>
<th style="width:14.28%"><?php echo Text::_('WED'); ?></th>
<th style="width:14.28%"><?php echo Text::_('THU'); ?></th>
<th style="width:14.28%"><?php echo Text::_('FRI'); ?></th>
<th style="width:14.28%"><?php echo Text::_('SAT'); ?></th>
<th style="width:14.28%"><?php echo Text::_('SUN'); ?></th>
</tr>
</thead>
<tbody>
<?php
$day = 1;
$started = false;
while ($day <= $daysInMonth) : ?>
<tr>
<?php for ($col = 0; $col < 7; $col++) :
if (!$started && $col < $firstWeekday) : ?>
<td class="text-muted bg-light">&nbsp;</td>
<?php
continue;
endif;
$started = true;
if ($day > $daysInMonth) : ?>
<td class="text-muted bg-light">&nbsp;</td>
<?php
continue;
endif;
$dateKey = sprintf('%04d-%02d-%02d', $year, $month, $day);
$isToday = ($dateKey === $today);
$cellClass = $isToday ? 'border border-primary border-2 bg-primary bg-opacity-10' : '';
$dayEvents = $events[$dateKey] ?? [];
?>
<td class="<?php echo $cellClass; ?>" style="vertical-align: top; min-height: 80px;">
<div class="fw-bold mb-1<?php echo $isToday ? ' text-primary' : ''; ?>">
<?php echo $day; ?>
<?php if ($isToday) : ?>
<small class="text-primary"><?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY'); ?></small>
<?php endif; ?>
</div>
<?php foreach ($dayEvents as $event) : ?>
<span class="badge <?php echo $statusClass($event->status); ?> mb-1 d-block text-truncate" style="max-width: 100%;"
title="<?php echo htmlspecialchars(ucfirst($event->service_type) . ': ' . $event->article_title . ' (' . $event->status . ')'); ?>">
<?php echo htmlspecialchars(ucfirst($event->service_type)); ?>:
<?php echo htmlspecialchars(mb_substr($event->article_title, 0, 20)); ?>
</span>
<?php endforeach; ?>
</td>
<?php
$day++;
endfor; ?>
</tr>
<?php endwhile; ?>
</tbody>
</table>
</div>
@@ -282,6 +282,10 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
class="list-group-item list-group-item-action"> class="list-group-item list-group-item-action">
<?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_LOGS'); ?> <?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_LOGS'); ?>
</a> </a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=analytics'); ?>"
class="list-group-item list-group-item-action">
<?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_ANALYTICS'); ?>
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteCross</name> <name>Content - MokoSuiteCross</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -212,8 +212,53 @@ XML;
$form->load($xml); $form->load($xml);
// Cross-post history panel for existing articles // AI Generate button for the Share Content panel
$articleId = Factory::getApplication()->input->getInt('id', 0); $articleId = Factory::getApplication()->input->getInt('id', 0);
$aiParams = ComponentHelper::getParams('com_mokosuitecross');
$aiEnabled = \in_array($aiParams->get('ai_provider', 'none'), ['claude', 'openai'], true);
if ($aiEnabled && $articleId > 0) {
$aiToken = Session::getFormToken();
$aiUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=ai.generate&format=raw&article_id=' . $articleId . '&' . $aiToken . '=1';
$aiButtonHtml = '<div class="mb-3">'
. '<button type="button" id="mokosuitecross-ai-btn" class="btn btn-sm btn-outline-info" onclick="mokosuitecrossAiGenerate()">'
. '<span class="icon-magic" aria-hidden="true"></span> '
. \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATE')
. '</button>'
. '<span id="mokosuitecross-ai-status" class="ms-2 small"></span>'
. '</div>'
. '<script>'
. 'function mokosuitecrossAiGenerate(){'
. 'var btn=document.getElementById("mokosuitecross-ai-btn");'
. 'var st=document.getElementById("mokosuitecross-ai-status");'
. 'btn.disabled=true;st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATING', true) . '";'
. 'fetch("' . $aiUrl . '")'
. '.then(function(r){return r.json();})'
. '.then(function(d){'
. 'btn.disabled=false;'
. 'if(!d.success){st.textContent=d.error||"Error";return;}'
. 'st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATED', true) . '";'
. 'var f=d.data;'
. 'var s=document.getElementById("jform_attribs_mokosuitecross_social_text");if(s)s.value=f.social;'
. 'var h=document.getElementById("jform_attribs_mokosuitecross_short_text");if(h)h.value=f.short;'
. 'var c=document.getElementById("jform_attribs_mokosuitecross_chat_text");if(c)c.value=f.chat;'
. 'var e=document.getElementById("jform_attribs_mokosuitecross_email_subject");if(e)e.value=f.email_subject;'
. '})'
. '.catch(function(){btn.disabled=false;st.textContent="Request failed";});'
. '}'
. '</script>';
$aiXml = '<?xml version="1.0"?>
<form><fields name="attribs"><fieldset name="mokosuitecross_share">
<field name="mokosuitecross_ai_generate" type="note"
label="" description="" />
</fieldset></fields></form>';
$form->load($aiXml);
$form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs');
}
// Cross-post history panel for existing articles
if ($articleId > 0) { if ($articleId > 0) {
$query = $db->getQuery(true) $query = $db->getQuery(true)
@@ -359,7 +404,7 @@ XML;
/** /**
* Dispatch cross-post when an article is saved and published. * Dispatch cross-post when an article is saved and published.
*/ */
public function onContentAfterSave(\Joomla\CMS\Event\Content\AfterSaveEvent $event): void public function onContentAfterSave($event): void
{ {
$context = $event->getContext(); $context = $event->getContext();
@@ -396,7 +441,7 @@ XML;
/** /**
* Dispatch cross-post when article state changes to published. * Dispatch cross-post when article state changes to published.
*/ */
public function onContentChangeState(\Joomla\CMS\Event\Content\ContentChangeStateEvent $event): void public function onContentChangeState($event): void
{ {
$context = $event->getContext(); $context = $event->getContext();
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ActivityPub (Fediverse)</name> <name>MokoSuiteCross - ActivityPub (Fediverse)</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Blogger</name> <name>MokoSuiteCross - Google Blogger</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Bluesky</name> <name>MokoSuiteCross - Bluesky</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Brevo (Sendinblue)</name> <name>MokoSuiteCross - Brevo (Sendinblue)</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Constant Contact</name> <name>MokoSuiteCross - Constant Contact</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ConvertKit</name> <name>MokoSuiteCross - ConvertKit</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Dev.to</name> <name>MokoSuiteCross - Dev.to</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Discord</name> <name>MokoSuiteCross - Discord</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Facebook / Meta</name> <name>MokoSuiteCross - Facebook / Meta</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ghost</name> <name>MokoSuiteCross - Ghost</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Business Profile</name> <name>MokoSuiteCross - Google Business Profile</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Chat</name> <name>MokoSuiteCross - Google Chat</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Hashnode</name> <name>MokoSuiteCross - Hashnode</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Instagram</name> <name>MokoSuiteCross - Instagram</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - LinkedIn</name> <name>MokoSuiteCross - LinkedIn</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mailchimp</name> <name>MokoSuiteCross - Mailchimp</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mastodon</name> <name>MokoSuiteCross - Mastodon</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Matrix / Element</name> <name>MokoSuiteCross - Matrix / Element</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Medium</name> <name>MokoSuiteCross - Medium</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteCalendar Events</name> <name>MokoSuiteCross - MokoSuiteCalendar Events</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteGallery</name> <name>MokoSuiteCross - MokoSuiteGallery</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Nostr</name> <name>MokoSuiteCross - Nostr</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ntfy Push Notifications</name> <name>MokoSuiteCross - Ntfy Push Notifications</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Pinterest</name> <name>MokoSuiteCross - Pinterest</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Reddit</name> <name>MokoSuiteCross - Reddit</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - RSS Feed</name> <name>MokoSuiteCross - RSS Feed</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - SendGrid</name> <name>MokoSuiteCross - SendGrid</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Slack</name> <name>MokoSuiteCross - Slack</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Microsoft Teams</name> <name>MokoSuiteCross - Microsoft Teams</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Telegram</name> <name>MokoSuiteCross - Telegram</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Threads (Meta)</name> <name>MokoSuiteCross - Threads (Meta)</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,2 +1,3 @@
PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - TikTok" PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - TikTok"
PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok." PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok via Content Posting API. Supports video uploads (PULL_FROM_URL) and photo carousels (up to 35 images)."
PLG_MOKOSUITECROSS_TIKTOK_AUDIT_WARNING="Unverified TikTok developer apps can only create private posts. To publish publicly, your app must pass TikTok's Content Posting API audit. Visit the TikTok Developer Portal to submit your app for review."
@@ -17,13 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
/**
* TikTok service plugin for MokoSuiteCross.
*
* API: https://open.tiktokapis.com/v2/post/publish/content/init/
*/
class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
private const API_BASE = 'https://open.tiktokapis.com/v2/post/publish/';
private const MAX_PHOTO_IMAGES = 35;
private const MAX_POLL_ATTEMPTS = 10;
private const POLL_INTERVAL_SECONDS = 3;
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
@@ -47,28 +47,129 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']]; return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']];
} }
if (empty($media[0])) { if (empty($media)) {
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']]; return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']];
} }
$postData = json_encode([ $postingMode = $params['posting_mode'] ?? 'DIRECT_POST';
$privacyLevel = $params['privacy_level'] ?? 'SELF_ONLY';
$caption = mb_substr($message, 0, 2200);
$title = mb_substr(strip_tags($message), 0, 150);
if ($this->isVideoUrl($media[0])) {
return $this->publishVideo($token, $title, $caption, $media[0], $postingMode, $privacyLevel);
}
return $this->publishPhotos($token, $title, $caption, $media, $postingMode, $privacyLevel);
}
private function publishVideo(string $token, string $title, string $caption, string $videoUrl, string $postingMode, string $privacyLevel): array
{
$payload = [
'post_info' => [ 'post_info' => [
'title' => mb_substr(strip_tags($message), 0, 150), 'title' => $title,
'description' => mb_substr($message, 0, 2200), 'description' => $caption,
'privacy_level' => 'SELF_ONLY', 'privacy_level' => $privacyLevel,
'disable_comment' => false, 'disable_comment' => false,
], ],
'source_info' => [ 'source_info' => [
'source' => 'PULL_FROM_URL', 'source' => 'PULL_FROM_URL',
'video_url' => $media[0], 'video_url' => $videoUrl,
], ],
'post_mode' => $postingMode,
];
$result = $this->apiPost(self::API_BASE . 'video/init/', $token, $payload);
if (!$result['success']) {
return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']];
}
$publishId = $result['data']['data']['publish_id'] ?? '';
if (empty($publishId)) {
return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']];
}
return $this->pollPublishStatus($token, $publishId, $result['data']);
}
private function publishPhotos(string $token, string $title, string $caption, array $media, string $postingMode, string $privacyLevel): array
{
$images = \array_slice($media, 0, self::MAX_PHOTO_IMAGES);
$photoImages = [];
foreach ($images as $url) {
$photoImages[] = ['image_url' => $url];
}
$payload = [
'post_info' => [
'title' => $title,
'description' => $caption,
'privacy_level' => $privacyLevel,
'disable_comment' => false,
],
'source_info' => [
'source' => 'PULL_FROM_URL',
'photo_images' => $photoImages,
],
'post_mode' => $postingMode,
'media_type' => 'PHOTO',
];
$result = $this->apiPost(self::API_BASE . 'content/init/', $token, $payload);
if (!$result['success']) {
return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']];
}
$publishId = $result['data']['data']['publish_id'] ?? '';
if (empty($publishId)) {
return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']];
}
return $this->pollPublishStatus($token, $publishId, $result['data']);
}
private function pollPublishStatus(string $token, string $publishId, array $initResponse): array
{
for ($i = 0; $i < self::MAX_POLL_ATTEMPTS; $i++) {
sleep(self::POLL_INTERVAL_SECONDS);
$statusResult = $this->apiPost(self::API_BASE . 'status/fetch/', $token, [
'publish_id' => $publishId,
]); ]);
$ch = curl_init(); if (!$statusResult['success']) {
continue;
}
$status = $statusResult['data']['data']['status'] ?? '';
if ($status === 'PUBLISH_COMPLETE') {
$postId = $statusResult['data']['data']['publicaly_available_post_id']
?? $statusResult['data']['data']['post_id']
?? $publishId;
return ['success' => true, 'platform_post_id' => (string) $postId, 'response' => $statusResult['data']];
}
if ($status === 'FAILED') {
$failReason = $statusResult['data']['data']['fail_reason'] ?? 'Unknown error';
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => $failReason, 'data' => $statusResult['data']]];
}
}
return ['success' => true, 'platform_post_id' => $publishId, 'response' => array_merge($initResponse, ['note' => 'Publish initiated but status polling timed out'])];
}
private function apiPost(string $url, string $token, array $payload): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/',
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData, CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
@@ -77,24 +178,26 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
$response = curl_exec($ch); $response = curl_exec($ch);
if ($response === false) { if ($response === false) {
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
return ['success' => false, 'data' => ['error' => 'Connection error: ' . $curlError]];
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
} }
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
$data = json_decode($response, true) ?: []; $data = json_decode($response, true) ?: [];
if ($httpCode >= 200 && $httpCode < 300) { if ($httpCode >= 200 && $httpCode < 300) {
return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; return ['success' => true, 'data' => $data];
} }
return ['success' => false, 'platform_post_id' => '', 'response' => $data]; return ['success' => false, 'data' => $data];
}
private function isVideoUrl(string $url): bool
{
return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm|mkv)(\?|$)/i', $url);
} }
public function validateCredentials(array $credentials): array public function validateCredentials(array $credentials): array
@@ -129,6 +232,6 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
public function getSupportedMediaTypes(): array public function getSupportedMediaTypes(): array
{ {
return ['image', 'video']; return ['image', 'video', 'carousel'];
} }
} }
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - TikTok</name> <name>MokoSuiteCross - TikTok</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Tumblr</name> <name>MokoSuiteCross - Tumblr</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - X / Twitter</name> <name>MokoSuiteCross - X / Twitter</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Generic Webhook</name> <name>MokoSuiteCross - Generic Webhook</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WhatsApp Business</name> <name>MokoSuiteCross - WhatsApp Business</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WordPress</name> <name>MokoSuiteCross - WordPress</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Youtube</name> <name>MokoSuiteCross - Youtube</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross</name> <name>System - MokoSuiteCross</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Events</name> <name>System - MokoSuiteCross Events</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Gallery</name> <name>System - MokoSuiteCross Gallery</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteCross Queue Processor</name> <name>Task - MokoSuiteCross Queue Processor</name>
<version>01.08.42</version> <version>01.11.02</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>

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