Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38523382b2 |
@@ -1,72 +1 @@
|
||||
# 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"
|
||||
IyBDb3B5cmlnaHQgKEMpIDIwMjYgTW9rbyBDb25zdWx0aW5nIDxoZWxsb0Btb2tvY29uc3VsdGluZy50ZWNoPgojCiMgU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEdQTC0zLjAtb3ItbGF0ZXIKIwojIEZJTEUgSU5GT1JNQVRJT04KIyBERUZHUk9VUDogR2l0ZWEuV29ya2Zsb3cKIyBJTkdST1VQOiBtb2tvY2xpLlVuaXZlcnNhbAojIFJFUE86IGh0dHBzOi8vZ2l0Lm1va29jb25zdWx0aW5nLnRlY2gvTW9rb0NvbnN1bHRpbmcvbW9rb2NsaQojIFBBVEg6IC8ubW9rb2dpdGVhL3dvcmtmbG93cy9jaS1pc3N1ZS1yZXBvcnRlci55bWwKIyBWRVJTSU9OOiAwMS4wMC4wMAojIEJSSUVGOiBSZXVzYWJsZSB3b3JrZmxvdyDigJQgY3JlYXRlcy91cGRhdGVzIGEgR2l0ZWEgaXNzdWUgd2hlbiBhIENJIGdhdGUgZmFpbHMuCiMgICAgICAgIENsb25lcyBNb2tvQ0xJIGFuZCBydW5zIGNsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaC4KCm5hbWU6ICJVbml2ZXJzYWw6IENJIElzc3VlIFJlcG9ydGVyIgoKb246CiAgd29ya2Zsb3dfY2FsbDoKICAgIGlucHV0czoKICAgICAgZ2F0ZToKICAgICAgICBkZXNjcmlwdGlvbjogIkNJIGdhdGUgbmFtZSAoZS5nLiBQUiBWYWxpZGF0aW9uLCBSZXBvc2l0b3J5IEhlYWx0aCkiCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgZGV0YWlsczoKICAgICAgICBkZXNjcmlwdGlvbjogIkh1bWFuLXJlYWRhYmxlIGZhaWx1cmUgZGVzY3JpcHRpb24iCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgc2V2ZXJpdHk6CiAgICAgICAgZGVzY3JpcHRpb246ICJlcnJvciBvciB3YXJuaW5nIgogICAgICAgIHJlcXVpcmVkOiBmYWxzZQogICAgICAgIHR5cGU6IHN0cmluZwogICAgICAgIGRlZmF1bHQ6ICJlcnJvciIKICAgICAgd29ya2Zsb3c6CiAgICAgICAgZGVzY3JpcHRpb246ICJXb3JrZmxvdyBuYW1lIGZvciB0aGUgaXNzdWUgdGl0bGUiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgICAgZGVmYXVsdDogIiIKICAgIHNlY3JldHM6CiAgICAgIE1PS09HSVRFQV9UT0tFTjoKICAgICAgICByZXF1aXJlZDogdHJ1ZQoKZW52OgogIEZPUkNFX0pBVkFTQ1JJUFRfQUNUSU9OU19UT19OT0RFMjQ6IHRydWUKCmpvYnM6CiAgcmVwb3J0OgogICAgbmFtZTogIlJlcG9ydDogJHt7IGlucHV0cy5nYXRlIH19IgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAoKICAgIHN0ZXBzOgogICAgICAtIG5hbWU6IENsb25lIE1va29DTEkKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgTU9LT0dJVEVBX1VSTD0iJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fSIKICAgICAgICAgIGdpdCBjbG9uZSAtLWRlcHRoIDEgLS1maWx0ZXI9YmxvYjpub25lIC0tc3BhcnNlICIke01PS09HSVRFQV9VUkx9L01va29Db25zdWx0aW5nL01va29DTEkuZ2l0IiAvdG1wL21va29jbGkKICAgICAgICAgIGNkIC90bXAvbW9rb2NsaSAmJiBnaXQgc3BhcnNlLWNoZWNrb3V0IHNldCBjbGkvY2lfaXNzdWVfcmVwb3J0ZXIuc2gKCiAgICAgIC0gbmFtZTogUmVwb3J0IENJIGZhaWx1cmUKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgICAgTU9LT0dJVEVBX1VSTDogJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgY2htb2QgK3ggL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaAogICAgICAgICAgL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaCBcCiAgICAgICAgICAgIC0tZ2F0ZSAiJHt7IGlucHV0cy5nYXRlIH19IiBcCiAgICAgICAgICAgIC0tZGV0YWlscyAiJHt7IGlucHV0cy5kZXRhaWxzIH19IiBcCiAgICAgICAgICAgIC0tc2V2ZXJpdHkgIiR7eyBpbnB1dHMuc2V2ZXJpdHkgfX0iIFwKICAgICAgICAgICAgLS13b3JrZmxvdyAiJHt7IGlucHV0cy53b3JrZmxvdyB9fSIK
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.08.56
|
||||
# VERSION: 01.08.44
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+1
-16
@@ -2,17 +2,6 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Visual post calendar**: Monthly calendar grid view showing scheduled, queued, and posted cross-posts with status badges (#160)
|
||||
- **Calendar navigation**: Month-by-month navigation with today highlighting (#160)
|
||||
- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165)
|
||||
- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range
|
||||
- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day
|
||||
- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload
|
||||
- **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157)
|
||||
- **Social image config**: Background color, text color, overlay style, and site name override in component options (#157)
|
||||
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
|
||||
- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings
|
||||
- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields
|
||||
- **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries
|
||||
- **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20)
|
||||
- **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts
|
||||
@@ -32,10 +21,6 @@
|
||||
- **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162)
|
||||
- **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162)
|
||||
- **Facebook draft posts**: Save feed posts as unpublished drafts (#162)
|
||||
- **TikTok video upload**: PULL_FROM_URL video publishing via video/init endpoint with status polling (#164)
|
||||
- **TikTok photo carousel**: Up to 35 image carousel posts via content/init endpoint (#164)
|
||||
- **TikTok posting mode**: Configurable DIRECT_POST or MEDIA_UPLOAD (sends to TikTok inbox for in-app editing) (#164)
|
||||
- **TikTok audit warning**: Language string explaining that unverified apps can only create private posts (#164)
|
||||
|
||||
### Fixed
|
||||
- Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()`
|
||||
@@ -104,7 +89,7 @@
|
||||
## [01.03.00] --- 2026-06-21
|
||||
|
||||
|
||||
<!-- VERSION: 01.08.56 -->
|
||||
<!-- VERSION: 01.08.44 -->
|
||||
|
||||
All notable changes to MokoSuiteCross will be documented in this file.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
|
||||
VERSION: 01.08.56
|
||||
VERSION: 01.08.44
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Community expectations and enforcement guidelines
|
||||
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteCross
|
||||
|
||||
<!-- VERSION: 01.08.56 -->
|
||||
<!-- VERSION: 01.08.44 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 01.08.56
|
||||
VERSION: 01.08.44
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -227,95 +227,6 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ai" label="COM_MOKOSUITECROSS_CONFIG_AI">
|
||||
<field
|
||||
name="ai_provider"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC"
|
||||
default="none">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE</option>
|
||||
<option value="claude">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE</option>
|
||||
<option value="openai">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI</option>
|
||||
</field>
|
||||
<field
|
||||
name="ai_api_key"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_model"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_MODEL"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC"
|
||||
hint="claude-haiku-4-5 / gpt-4o-mini"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_tone"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_TONE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC"
|
||||
default="professional"
|
||||
showon="ai_provider:claude,openai">
|
||||
<option value="professional">COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL</option>
|
||||
<option value="friendly">COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY</option>
|
||||
<option value="casual">COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="social_image" label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE">
|
||||
<field
|
||||
name="social_image_enabled"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC"
|
||||
default="0"
|
||||
class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="social_image_bg_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
|
||||
default="#1a1a2e"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_text_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
|
||||
default="#ffffff"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_font_size"
|
||||
type="number"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC"
|
||||
default="48"
|
||||
min="24"
|
||||
max="96"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_show_site_name"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="social_image_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
|
||||
<field
|
||||
name="category_rules_note"
|
||||
|
||||
@@ -549,86 +549,7 @@ COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC="Full URL to your YOURL
|
||||
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"
|
||||
|
||||
; Social Image Generator
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE="Social Image Generator"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR="Background Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC="Default background color for generated OG images when no article image is available."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR="Text Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Color for the title and site name text overlay on generated images."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY="Image Overlay"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DESC="Darken or lighten the background image to improve text readability."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_NONE="None"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_LIGHT="Light"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DARK="Dark"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME="Site Name Override"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME_DESC="Custom site name shown at the bottom of generated images. Leave blank to use the Joomla site name."
|
||||
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
|
||||
|
||||
; Analytics
|
||||
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Time Period"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER="Service"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HOURLY="Hourly Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAILY="Day of Week Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough posting data to generate recommendations. Post at least 3 times per time slot over the selected period."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS="%d of %d successful"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN="Sun"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_MON="Mon"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE="Tue"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_WED="Wed"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_THU="Thu"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI="Fri"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT="Sat"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH="High success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM="Medium success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW="Low success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data"
|
||||
COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days"
|
||||
COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days"
|
||||
; Category Rules
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release."
|
||||
|
||||
; Posting Analytics
|
||||
COM_MOKOSUITECROSS_ANALYTICS_FILTER_SERVICE="Service"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Period"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_POSTS_COUNT="%d posts"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Posting Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="No posting data available for the selected period."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LESS="Less"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_MORE="More"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_BREAKDOWN="Service Breakdown"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SERVICE="Service"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_TOTAL="Total"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SUCCESS="Success"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_FAILED="Failed"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SUCCESS_RATE="Success Rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_AVG_PER_DAY="Avg/Day"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokosuitecross</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.45 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.46 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.47 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.49 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.50 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.51 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.52 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.53 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.54 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.55 — no schema changes */
|
||||
@@ -1 +0,0 @@
|
||||
/* 01.08.56 — no schema changes */
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
|
||||
|
||||
class AnalyticsController extends BaseController
|
||||
{
|
||||
public function getHeatmapData(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceType = $this->input->getCmd('service_type', '');
|
||||
$days = $this->input->getInt('days', 90);
|
||||
|
||||
$heatmap = AnalyticsHelper::getPostingHeatmap($serviceType, $days);
|
||||
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, $days);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'heatmap' => $heatmap,
|
||||
'best_times' => $bestTimes,
|
||||
]);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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();
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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';
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Analytics;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $heatmap = [];
|
||||
public array $bestTimes = [];
|
||||
public array $serviceBreakdown = [];
|
||||
public array $serviceTypes = [];
|
||||
public string $serviceFilter = '';
|
||||
public int $days = 90;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$input = Factory::getApplication()->input;
|
||||
|
||||
$this->serviceFilter = $input->getCmd('service_type', '');
|
||||
$this->days = $input->getInt('days', 90);
|
||||
$this->heatmap = AnalyticsHelper::getPostingHeatmap($this->serviceFilter, $this->days);
|
||||
$this->bestTimes = AnalyticsHelper::getBestTimes($this->serviceFilter, $this->days);
|
||||
$this->serviceBreakdown = AnalyticsHelper::getServiceBreakdown($this->days);
|
||||
$this->serviceTypes = AnalyticsHelper::getServiceTypes();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
MokoSuiteCrossHelper::addSubmenu('analytics');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('MokoSuiteCross -- Posting Analytics', 'chart');
|
||||
|
||||
$toolbar = Toolbar::getInstance('toolbar');
|
||||
$toolbar->appendButton(
|
||||
'Link',
|
||||
'home',
|
||||
'COM_MOKOSUITECROSS_SUBMENU_DASHBOARD',
|
||||
Route::_('index.php?option=com_mokosuitecross&view=dashboard', false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
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 = $g = $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 = [], hourSuccess = [], 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 = [], 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>
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
|
||||
|
||||
$year = $this->year;
|
||||
$month = $this->month;
|
||||
$events = $this->events;
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$prevMonth = $month - 1;
|
||||
$prevYear = $year;
|
||||
|
||||
if ($prevMonth < 1) {
|
||||
$prevMonth = 12;
|
||||
$prevYear--;
|
||||
}
|
||||
|
||||
$nextMonth = $month + 1;
|
||||
$nextYear = $year;
|
||||
|
||||
if ($nextMonth > 12) {
|
||||
$nextMonth = 1;
|
||||
$nextYear++;
|
||||
}
|
||||
|
||||
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
|
||||
$daysInMonth = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$firstWeekday = ((int) date('N', mktime(0, 0, 0, $month, 1, $year))) - 1;
|
||||
|
||||
$statusClass = static function (string $status): string {
|
||||
return match ($status) {
|
||||
'posted' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
default => 'bg-warning text-dark',
|
||||
};
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $prevYear . '&month=' . $prevMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<span class="icon-chevron-left" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH'); ?>
|
||||
</a>
|
||||
<h3 class="mb-0"><?php echo htmlspecialchars($monthName . ' ' . $year); ?></h3>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $nextYear . '&month=' . $nextMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH'); ?>
|
||||
<span class="icon-chevron-right" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:14.28%"><?php echo Text::_('MON'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('TUE'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('WED'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('THU'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('FRI'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SAT'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SUN'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$day = 1;
|
||||
$started = false;
|
||||
|
||||
while ($day <= $daysInMonth) : ?>
|
||||
<tr>
|
||||
<?php for ($col = 0; $col < 7; $col++) :
|
||||
if (!$started && $col < $firstWeekday) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
|
||||
$started = true;
|
||||
|
||||
if ($day > $daysInMonth) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
|
||||
$dateKey = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$isToday = ($dateKey === $today);
|
||||
$cellClass = $isToday ? 'border border-primary border-2 bg-primary bg-opacity-10' : '';
|
||||
$dayEvents = $events[$dateKey] ?? [];
|
||||
?>
|
||||
<td class="<?php echo $cellClass; ?>" style="vertical-align: top; min-height: 80px;">
|
||||
<div class="fw-bold mb-1<?php echo $isToday ? ' text-primary' : ''; ?>">
|
||||
<?php echo $day; ?>
|
||||
<?php if ($isToday) : ?>
|
||||
<small class="text-primary"><?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY'); ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php foreach ($dayEvents as $event) : ?>
|
||||
<span class="badge <?php echo $statusClass($event->status); ?> mb-1 d-block text-truncate" style="max-width: 100%;"
|
||||
title="<?php echo htmlspecialchars(ucfirst($event->service_type) . ': ' . $event->article_title . ' (' . $event->status . ')'); ?>">
|
||||
<?php echo htmlspecialchars(ucfirst($event->service_type)); ?>:
|
||||
<?php echo htmlspecialchars(mb_substr($event->article_title, 0, 20)); ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<?php
|
||||
$day++;
|
||||
endfor; ?>
|
||||
</tr>
|
||||
<?php endwhile; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteCross</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -212,53 +212,8 @@ XML;
|
||||
|
||||
$form->load($xml);
|
||||
|
||||
// AI Generate button for the Share Content panel
|
||||
$articleId = Factory::getApplication()->input->getInt('id', 0);
|
||||
$aiParams = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$aiEnabled = \in_array($aiParams->get('ai_provider', 'none'), ['claude', 'openai'], true);
|
||||
|
||||
if ($aiEnabled && $articleId > 0) {
|
||||
$aiToken = Session::getFormToken();
|
||||
$aiUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=ai.generate&format=raw&article_id=' . $articleId . '&' . $aiToken . '=1';
|
||||
|
||||
$aiButtonHtml = '<div class="mb-3">'
|
||||
. '<button type="button" id="mokosuitecross-ai-btn" class="btn btn-sm btn-outline-info" onclick="mokosuitecrossAiGenerate()">'
|
||||
. '<span class="icon-magic" aria-hidden="true"></span> '
|
||||
. \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATE')
|
||||
. '</button>'
|
||||
. '<span id="mokosuitecross-ai-status" class="ms-2 small"></span>'
|
||||
. '</div>'
|
||||
. '<script>'
|
||||
. 'function mokosuitecrossAiGenerate(){'
|
||||
. 'var btn=document.getElementById("mokosuitecross-ai-btn");'
|
||||
. 'var st=document.getElementById("mokosuitecross-ai-status");'
|
||||
. 'btn.disabled=true;st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATING', true) . '";'
|
||||
. 'fetch("' . $aiUrl . '")'
|
||||
. '.then(function(r){return r.json();})'
|
||||
. '.then(function(d){'
|
||||
. 'btn.disabled=false;'
|
||||
. 'if(!d.success){st.textContent=d.error||"Error";return;}'
|
||||
. 'st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATED', true) . '";'
|
||||
. 'var f=d.data;'
|
||||
. 'var s=document.getElementById("jform_attribs_mokosuitecross_social_text");if(s)s.value=f.social;'
|
||||
. 'var h=document.getElementById("jform_attribs_mokosuitecross_short_text");if(h)h.value=f.short;'
|
||||
. 'var c=document.getElementById("jform_attribs_mokosuitecross_chat_text");if(c)c.value=f.chat;'
|
||||
. 'var e=document.getElementById("jform_attribs_mokosuitecross_email_subject");if(e)e.value=f.email_subject;'
|
||||
. '})'
|
||||
. '.catch(function(){btn.disabled=false;st.textContent="Request failed";});'
|
||||
. '}'
|
||||
. '</script>';
|
||||
|
||||
$aiXml = '<?xml version="1.0"?>
|
||||
<form><fields name="attribs"><fieldset name="mokosuitecross_share">
|
||||
<field name="mokosuitecross_ai_generate" type="note"
|
||||
label="" description="" />
|
||||
</fieldset></fields></form>';
|
||||
$form->load($aiXml);
|
||||
$form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs');
|
||||
}
|
||||
|
||||
// Cross-post history panel for existing articles
|
||||
$articleId = Factory::getApplication()->input->getInt('id', 0);
|
||||
|
||||
if ($articleId > 0) {
|
||||
$query = $db->getQuery(true)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - ActivityPub (Fediverse)</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Blogger</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Bluesky</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Brevo (Sendinblue)</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Constant Contact</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - ConvertKit</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Dev.to</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Discord</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Facebook / Meta</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Ghost</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Business Profile</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Chat</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Hashnode</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Instagram</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - LinkedIn</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Mailchimp</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Mastodon</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Matrix / Element</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Medium</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - MokoSuiteCalendar Events</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - MokoSuiteGallery</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Nostr</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Ntfy Push Notifications</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Pinterest</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Reddit</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - RSS Feed</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - SendGrid</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Slack</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Microsoft Teams</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Telegram</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Threads (Meta)</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - 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."
|
||||
PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok."
|
||||
|
||||
@@ -17,13 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
|
||||
@@ -47,129 +47,28 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']];
|
||||
}
|
||||
|
||||
if (empty($media)) {
|
||||
if (empty($media[0])) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']];
|
||||
}
|
||||
|
||||
$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 = [
|
||||
$postData = json_encode([
|
||||
'post_info' => [
|
||||
'title' => $title,
|
||||
'description' => $caption,
|
||||
'privacy_level' => $privacyLevel,
|
||||
'title' => mb_substr(strip_tags($message), 0, 150),
|
||||
'description' => mb_substr($message, 0, 2200),
|
||||
'privacy_level' => 'SELF_ONLY',
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $videoUrl,
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $media[0],
|
||||
],
|
||||
'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,
|
||||
]);
|
||||
|
||||
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);
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_POSTFIELDS => $postData,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
@@ -178,26 +77,24 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['success' => false, 'data' => ['error' => 'Connection error: ' . $curlError]];
|
||||
}
|
||||
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
|
||||
|
||||
}
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return ['success' => true, 'data' => $data];
|
||||
return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', '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);
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
@@ -232,6 +129,6 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'carousel'];
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - TikTok</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Tumblr</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - X / Twitter</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Generic Webhook</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - WhatsApp Business</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - WordPress</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Youtube</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteCross</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteCross Events</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteCross Gallery</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteCross Queue Processor</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteCross</name>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>MokoSuiteCross</name>
|
||||
<packagename>mokosuitecross</packagename>
|
||||
<version>01.08.56</version>
|
||||
<version>01.08.44</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user