diff --git a/.mokogitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md new file mode 100644 index 00000000..eb40760a --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/adr.md @@ -0,0 +1,110 @@ +--- +name: Architecture Decision Record (ADR) +about: Propose or document an architectural decision +title: '[ADR] ' +labels: 'architecture, decision' +assignees: '' + +--- + + +## ADR Number +ADR-XXXX + +## Status +- [ ] Proposed +- [ ] Accepted +- [ ] Deprecated +- [ ] Superseded by ADR-XXXX + +## Context +Describe the issue or problem that motivates this decision. + +## Decision +State the architecture decision and provide rationale. + +## Consequences +### Positive +- List positive consequences + +### Negative +- List negative consequences or trade-offs + +### Neutral +- List neutral aspects + +## Alternatives Considered +### Alternative 1 +- Description +- Pros +- Cons +- Why not chosen + +### Alternative 2 +- Description +- Pros +- Cons +- Why not chosen + +## Implementation Plan +1. Step 1 +2. Step 2 +3. Step 3 + +## Stakeholders +- **Decision Makers**: @user1, @user2 +- **Consulted**: @user3, @user4 +- **Informed**: team-name + +## Technical Details +### Architecture Diagram +``` +[Add diagram or link] +``` + +### Dependencies +- Dependency 1 +- Dependency 2 + +### Impact Analysis +- **Performance**: [Impact description] +- **Security**: [Impact description] +- **Scalability**: [Impact description] +- **Maintainability**: [Impact description] + +## Testing Strategy +- [ ] Unit tests +- [ ] Integration tests +- [ ] Performance tests +- [ ] Security tests + +## Documentation +- [ ] Architecture documentation updated +- [ ] API documentation updated +- [ ] Developer guide updated +- [ ] Runbook created + +## Migration Path +Describe how to migrate from current state to new architecture. + +## Rollback Plan +Describe how to rollback if issues occur. + +## Timeline +- **Proposal Date**: +- **Decision Date**: +- **Implementation Start**: +- **Expected Completion**: + +## References +- Related ADRs: +- External resources: +- RFCs: + +## Review Checklist +- [ ] Aligns with enterprise architecture principles +- [ ] Security implications reviewed +- [ ] Performance implications reviewed +- [ ] Cost implications reviewed +- [ ] Compliance requirements met +- [ ] Team consensus achieved diff --git a/.mokogitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..38a16a7d --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug Report +about: Report a bug or issue with the project +title: '[BUG] ' +labels: 'bug' +assignees: '' + +--- + + +## Bug Description +A clear and concise description of what the bug is. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +A clear and concise description of what actually happened. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Environment +- **Project**: [e.g., MokoDoliTools, moko-cassiopeia] +- **Version**: [e.g., 1.2.3] +- **Platform**: [e.g., Dolibarr 18.0, Joomla 5.0] +- **PHP Version**: [e.g., 8.1] +- **Database**: [e.g., MySQL 8.0, PostgreSQL 14] +- **Browser** (if applicable): [e.g., Chrome 120, Firefox 121] +- **OS**: [e.g., Ubuntu 22.04, Windows 11] + +## Additional Context +Add any other context about the problem here. + +## Possible Solution +If you have suggestions on how to fix the issue, please describe them here. + +## Checklist +- [ ] I have searched for similar issues before creating this one +- [ ] I have provided all the requested information +- [ ] I have tested this on the latest stable version +- [ ] I have checked the documentation and couldn't find a solution diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d4d49ec8 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,18 @@ +--- +blank_issues_enabled: true +contact_links: + - name: 💼 Enterprise Support + url: https://mokoconsulting.tech/enterprise + about: Enterprise-level support and consultation services + - name: 💬 Ask a Question + url: https://mokoconsulting.tech/ + about: Get help or ask questions through our website + - name: 📚 MokoStandards Documentation + url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + about: View our coding standards and best practices + - name: 🔒 Report a Security Vulnerability + url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new + about: Report security vulnerabilities privately (for critical issues) + - name: 💡 Community Discussions + url: https://github.com/orgs/mokoconsulting-tech/discussions + about: Join community discussions and Q&A diff --git a/.mokogitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 00000000..ed4dabc5 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,52 @@ +--- +name: Documentation Issue +about: Report an issue with documentation +title: '[DOCS] ' +labels: 'documentation' +assignees: '' + +--- + + +## Documentation Issue + +**Location**: + + +## Issue Type + +- [ ] Typo or grammar error +- [ ] Outdated information +- [ ] Missing documentation +- [ ] Unclear explanation +- [ ] Broken links +- [ ] Missing examples +- [ ] Other (specify below) + +## Description + + +## Current Content + +``` +Current text here +``` + +## Suggested Improvement + +``` +Suggested text here +``` + +## Additional Context + + +## Standards Alignment +- [ ] Follows MokoStandards documentation guidelines +- [ ] Uses en_US/en_GB localization +- [ ] Includes proper SPDX headers where applicable + +## Checklist +- [ ] I have searched for similar documentation issues +- [ ] I have provided a clear description +- [ ] I have suggested an improvement (if applicable) diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..7b76dc96 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,51 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' + +--- + + +## Feature Description +A clear and concise description of the feature you'd like to see. + +## Problem or Use Case +Describe the problem this feature would solve or the use case it addresses. +Ex. I'm always frustrated when [...] + +## Proposed Solution +A clear and concise description of what you want to happen. + +## Alternative Solutions +A clear and concise description of any alternative solutions or features you've considered. + +## Benefits +Describe how this feature would benefit users: +- Who would use this feature? +- What problems does it solve? +- What value does it add? + +## Implementation Details (Optional) +If you have ideas about how this could be implemented, share them here: +- Technical approach +- Files/components that might need changes +- Any concerns or challenges you foresee + +## Additional Context +Add any other context, mockups, or screenshots about the feature request here. + +## Relevant Standards +Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +- [ ] Accessibility (WCAG 2.1 AA) +- [ ] Localization (en_US/en_GB) +- [ ] Security best practices +- [ ] Code quality standards +- [ ] Other: [specify] + +## Checklist +- [ ] I have searched for similar feature requests before creating this one +- [ ] I have clearly described the use case and benefits +- [ ] I have considered alternative solutions +- [ ] This feature aligns with the project's goals and scope diff --git a/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md new file mode 100644 index 00000000..d808f790 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md @@ -0,0 +1,87 @@ +--- +name: Joomla Extension Issue +about: Report an issue with a Joomla extension +title: '[JOOMLA] ' +labels: 'joomla' +assignees: '' + +--- + + +## Issue Type +- [ ] Component Issue +- [ ] Module Issue +- [ ] Plugin Issue +- [ ] Template Issue + +## Extension Details +- **Extension Name**: [e.g., moko-cassiopeia] +- **Extension Version**: [e.g., 1.2.3] +- **Extension Type**: [Component / Module / Plugin / Template] + +## Joomla Environment +- **Joomla Version**: [e.g., 4.4.0, 5.0.0] +- **PHP Version**: [e.g., 8.1.0] +- **Database**: [MySQL / PostgreSQL / MariaDB] +- **Database Version**: [e.g., 8.0] +- **Server**: [Apache / Nginx / IIS] +- **Hosting**: [Shared / VPS / Dedicated / Cloud] + +## Issue Description +Provide a clear and detailed description of the issue. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. Configure '...' +4. See error + +## Expected Behavior +What you expected to happen. + +## Actual Behavior +What actually happened. + +## Error Messages +``` +# Paste any error messages from Joomla error logs +# Location: administrator/logs/error.php +``` + +## Browser Console Errors +```javascript +// Paste any JavaScript console errors (F12 in browser) +``` + +## Screenshots +Add screenshots to help explain the issue. + +## Configuration +```ini +# Paste extension configuration (sanitize sensitive data) +``` + +## Installed Extensions +List other installed extensions that might conflict: +- Extension 1 (version) +- Extension 2 (version) + +## Template Overrides +- [ ] Using template overrides +- [ ] Custom CSS +- [ ] Custom JavaScript + +## Additional Context +- **Multilingual Site**: [Yes / No] +- **Cache Enabled**: [Yes / No] +- **Debug Mode**: [Yes / No] +- **SEF URLs**: [Yes / No] + +## Checklist +- [ ] I have cleared Joomla cache +- [ ] I have disabled other extensions to test for conflicts +- [ ] I have checked Joomla error logs +- [ ] I have tested with a default Joomla template +- [ ] I have checked browser console for JavaScript errors +- [ ] I have searched for similar issues +- [ ] I am using a supported Joomla version diff --git a/.mokogitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..3175013b --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/question.md @@ -0,0 +1,82 @@ +--- +name: Question +about: Ask a question about usage, features, or best practices +title: '[QUESTION] ' +labels: ['question'] +assignees: ['jmiller'] +--- + + +## Question + +**Your question:** + + +## Context + +**What are you trying to accomplish?** + + +**What have you already tried?** + + +**Category**: +- [ ] Script usage +- [ ] Configuration +- [ ] Workflow setup +- [ ] Documentation interpretation +- [ ] Best practices +- [ ] Integration +- [ ] Other: __________ + +## Environment (if relevant) + +**Your setup**: +- Operating System: +- Version: + +## What You've Researched + +**Documentation reviewed**: +- [ ] README.md +- [ ] Project documentation +- [ ] Other (specify): __________ + +**Similar issues/questions found**: +- # +- # + +## Expected Outcome + +**What result are you hoping for?** + + +## Code/Configuration Samples + +**Relevant code or configuration** (if applicable): + +```bash +# Your code here +``` + +## Additional Context + +**Any other relevant information:** + + +**Screenshots** (if helpful): + + +## Urgency + +- [ ] Urgent (blocking work) +- [ ] Normal (can work on other things meanwhile) +- [ ] Low priority (just curious) + +## Checklist + +- [ ] I have searched existing issues and discussions +- [ ] I have reviewed relevant documentation +- [ ] I have provided sufficient context +- [ ] I have included code/configuration samples if relevant +- [ ] This is a genuine question (not a bug report or feature request) diff --git a/.mokogitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md new file mode 100644 index 00000000..6f09af78 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/rfc.md @@ -0,0 +1,126 @@ +--- +name: Request for Comments (RFC) +about: Propose a significant change for community discussion +title: '[RFC] ' +labels: 'rfc, discussion' +assignees: '' + +--- + + +## RFC Summary +One-paragraph summary of the proposal. + +## Motivation +Why are we doing this? What use cases does it support? What is the expected outcome? + +## Detailed Design +### Overview +Provide a detailed explanation of the proposed change. + +### API Changes (if applicable) +```php +// Before +function oldApi($param1) { } + +// After +function newApi($param1, $param2) { } +``` + +### User Experience Changes +Describe how users will interact with this change. + +### Implementation Approach +High-level implementation strategy. + +## Drawbacks +Why should we *not* do this? + +## Alternatives +What other designs have been considered? What is the impact of not doing this? + +### Alternative 1 +- Description +- Trade-offs + +### Alternative 2 +- Description +- Trade-offs + +## Adoption Strategy +How will existing users adopt this? Is this a breaking change? + +### Migration Guide +```bash +# Steps to migrate +``` + +### Deprecation Timeline +- **Announcement**: +- **Deprecation**: +- **Removal**: + +## Unresolved Questions +- Question 1 +- Question 2 + +## Future Possibilities +What future work does this enable? + +## Impact Assessment +### Performance +Expected performance impact. + +### Security +Security considerations and implications. + +### Compatibility +- **Backward Compatible**: [Yes / No] +- **Breaking Changes**: [List] + +### Maintenance +Long-term maintenance considerations. + +## Community Input +### Stakeholders +- [ ] Core team +- [ ] Module developers +- [ ] End users +- [ ] Enterprise customers + +### Feedback Period +**Duration**: [e.g., 2 weeks] +**Deadline**: [date] + +## Implementation Timeline +### Phase 1: Design +- [ ] RFC discussion +- [ ] Design finalization +- [ ] Approval + +### Phase 2: Implementation +- [ ] Core implementation +- [ ] Tests +- [ ] Documentation + +### Phase 3: Release +- [ ] Beta release +- [ ] Feedback collection +- [ ] Stable release + +## Success Metrics +How will we measure success? +- Metric 1 +- Metric 2 + +## References +- Related RFCs: +- External documentation: +- Prior art: + +## Open Questions for Community +1. Question 1? +2. Question 2? + +--- +**Note**: This RFC is open for community discussion. Please provide feedback in the comments below. diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md new file mode 100644 index 00000000..f57b284d --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/security.md @@ -0,0 +1,51 @@ +--- +name: Security Vulnerability Report +about: Report a security vulnerability (use only for non-critical issues) +title: '[SECURITY] ' +labels: 'security' +assignees: '' + +--- + + +## ⚠️ IMPORTANT: Private Disclosure Required + +**For critical security vulnerabilities, DO NOT use this template.** +Follow the process in [SECURITY.md](../SECURITY.md) for responsible disclosure. + +Use this template only for: +- Security improvements +- Non-critical security suggestions +- Security documentation updates + +--- + +## Security Issue + +**Severity**: + + +## Description + + +## Affected Components + + +## Suggested Mitigation + + +## Standards Reference +Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +- [ ] SPDX license identifiers +- [ ] Secret management +- [ ] Dependency security +- [ ] Access control +- [ ] Other: [specify] + +## Additional Context + + +## Checklist +- [ ] This is NOT a critical vulnerability requiring private disclosure +- [ ] I have reviewed the SECURITY.md policy +- [ ] I have provided sufficient detail for evaluation diff --git a/.mokogitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md new file mode 100644 index 00000000..63284217 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/version.md @@ -0,0 +1,24 @@ +--- +name: Version Bump +about: Request or track a version change +title: '[VERSION] ' +labels: 'version, type: version' +assignees: 'jmiller' +--- + +## Version Change + +**Current version**: +**Requested version**: +**Change type**: + +## Reason + + + +## Checklist + +- [ ] README.md `VERSION:` field updated +- [ ] CHANGELOG.md entry added +- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: ``) +- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 11958bdc..35aaa255 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.08.61 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e2df76..46364027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,49 @@ # Changelog ## [Unreleased] -## [01.08.00] --- 2026-06-23 +### 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 +- **Instagram carousel**: Multi-image/video posts via Meta carousel container flow (up to 10 items) +- **Instagram Reels**: Short-form video publishing via REELS media type +- **Instagram Stories**: Image and video story publishing via STORIES media type +- **Instagram alt text**: Alt text support for image containers +- **Nostr plugin**: Full NIP-01 WebSocket relay publishing with BIP-340 Schnorr signatures (pure PHP, requires ext-gmp) +- **Nostr**: Publishes kind-1 text note events to multiple relays with automatic failover +- **Nostr**: Raw WebSocket client using stream_socket_client (no external dependencies) +- **Nostr**: Public key derivation and event signing via secp256k1 elliptic curve math +- **Threads carousel**: Support up to 20-item carousel posts via Threads API multi-container flow +- **Threads polls**: Poll creation support via poll_options parameter (2-4 options) +- **Threads spoiler tags**: Content warning / spoiler flag support for Threads posts +- **Threads text-only optimization**: Simplified single-step flow for text-only posts without media +- **Facebook Reels**: Publish video Reels via Graph API video_reels endpoint (#162) +- **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162) +- **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162) +- **Facebook draft posts**: Save feed posts as unpublished drafts (#162) +- **TikTok video upload**: PULL_FROM_URL video publishing via video/init endpoint with status polling (#164) +- **TikTok photo carousel**: Up to 35 image carousel posts via content/init endpoint (#164) +- **TikTok posting mode**: Configurable DIRECT_POST or MEDIA_UPLOAD (sends to TikTok inbox for in-app editing) (#164) +- **TikTok audit warning**: Language string explaining that unverified apps can only create private posts (#164) +- **Link shortening**: Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder (#159) +- **Site frontend**: Public-facing cross-post list and detail views for site visitors (#133) +- **Social preview**: AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in article editor (#156) +- **PHPUnit test suite**: Unit tests for models, helpers, and service plugins (#132) -## [01.08.00] --- 2026-06-23 +### Fixed +- **PreviewController**: Add ACL check and parameterized query to prevent unauthorized article access (IDOR) +- Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` ## [01.07.00] --- 2026-06-23 @@ -16,6 +56,7 @@ ### Fixed - **License warning**: Removed duplicate from system plugin (install script already shows it) +- **Content plugin**: Fixed func_get_arg crash when non-article content is saved (e.g. update sites, installer) ## [01.05.00] --- 2026-06-23 @@ -52,3 +93,24 @@ - **Bluesky**: Replaced md5() with hash('sha256', ...) for cache key - **ServiceController**: Exception details no longer exposed to client - **License warning**: Removed duplicate from system plugin -- install script already shows it with direct edit link + +## [01.04.01] --- 2026-06-21 + + +## [01.04.01] --- 2026-06-21 + + +## [01.04.00] --- 2026-06-21 + +### Fixed +- **Package manifest**: Added missing `plg_system_mokosuitecross_events` and `plg_system_mokosuitecross_gallery` to `pkg_mokosuitecross.xml` — these system plugins were not installed with the package +- **Cleanup**: Removed old `src/` directory (pre-rename cruft with `mokojoomcross` files) + +## [01.03.00] --- 2026-06-21 + + + + +All notable changes to MokoSuiteCross will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). diff --git a/README.md b/README.md index 00a7425e..c40fb92a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteCross - + Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6. @@ -16,8 +16,14 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform - **Post queue** — Scheduled posting, retry on failure, detailed delivery logs - **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx}) - **Share Content panel** — Per-article fields for platform-optimized text (social, short, chat, email) with image picker +- **AI caption generation** — Generate platform-optimized captions using Claude or OpenAI with one click +- **Social preview** — AJAX platform mockups (Twitter, Facebook, LinkedIn, Instagram, Mastodon, Bluesky) in the article editor +- **Social image generator** — Generate Open Graph images with article title overlay using PHP GD +- **Link shortening** — Shorten URLs via Bitly, Rebrandly, or YOURLS with {url_short} placeholder - **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares - **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token +- **Post calendar** — Visual monthly calendar view of scheduled and completed cross-posts +- **Posting analytics** — Best time to post heatmap with per-service breakdown and recommendations - **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms) - **Post history** — Track what was posted where, with platform response data - **Evergreen re-sharing** — Automatically re-share articles on a configurable interval @@ -82,7 +88,7 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform | RSS Feed | `plg_mokosuitecross_rssfeed` | Implemented | | ActivityPub | `plg_mokosuitecross_activitypub` | Implemented | | Google Business | `plg_mokosuitecross_googlebusiness` | Implemented | -| Nostr | `plg_mokosuitecross_nostr` | Stub (WebSocket deferred) | +| Nostr | `plg_mokosuitecross_nostr` | Implemented | ## Installation diff --git a/composer.json b/composer.json index 10afe3ae..3cab6524 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "mokoconsulting/mokojoomgallery", - "description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display", + "name": "mokoconsulting/mokosuitecross", + "description": "Cross-posting Joomla content to social media, email marketing, and chat platforms", "type": "joomla-package", "version": "01.00.00", "license": "GPL-3.0-or-later", @@ -15,12 +15,24 @@ "php": ">=8.1" }, "require-dev": { + "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3.7", "phpstan/phpstan": "^1.10", - "joomla/coding-standards": "3.0.x-dev" + "joomla/coding-standards": "dev-3.x-dev" + }, + "autoload": { + "psr-4": { + "Joomla\\Component\\MokoSuiteCross\\Administrator\\": "source/packages/com_mokosuitecross/src/", + "Joomla\\Component\\MokoSuiteCross\\Site\\": "source/packages/com_mokosuitecross/site/src/", + "Joomla\\Plugin\\Content\\MokoSuiteCross\\": "source/packages/plg_content_mokosuitecross/src/", + "Joomla\\Plugin\\System\\MokoSuiteCross\\": "source/packages/plg_system_mokosuitecross/src/" + } + }, + "autoload-dev": { + "psr-4": { + "MokoSuiteCross\\Tests\\": "tests/" + } }, - "minimum-stability": "dev", - "prefer-stable": true, "config": { "sort-packages": true } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..a8b7ba93 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests/Unit + + + + + source/packages/com_mokosuitecross/src + source/packages/plg_content_mokosuitecross/src + source/packages/plg_system_mokosuitecross/src + + + diff --git a/source/language/en-GB/pkg_mokosuitecross.sys.ini b/source/language/en-GB/pkg_mokosuitecross.sys.ini index 21331a3d..e5ef1809 100644 --- a/source/language/en-GB/pkg_mokosuitecross.sys.ini +++ b/source/language/en-GB/pkg_mokosuitecross.sys.ini @@ -3,6 +3,6 @@ ; License: GPL-3.0-or-later PKG_MOKOSUITECROSS="MokoSuiteCross" -PKG_MOKOSUITECROSS_DESCRIPTION="Cross-posting Joomla content to social media, email marketing, and chat platforms. Automatically publish articles to Facebook, X/Twitter, LinkedIn, Mastodon, Bluesky, Mailchimp, Telegram, Discord, and Slack." +PKG_MOKOSUITECROSS_DESCRIPTION="Cross-post Joomla articles to 38 platforms including Facebook, Instagram, X/Twitter, LinkedIn, Threads, Mastodon, Bluesky, Nostr, TikTok, YouTube, Pinterest, Reddit, Medium, Telegram, Discord, Slack, Teams, Mailchimp, SendGrid, Brevo, and more. Features scheduled posting, template placeholders, UTM tagging, link shortening, caption rotation, and per-article service selection." PKG_MOKOSUITECROSS_PHP_VERSION_ERROR="MokoSuiteCross requires PHP %s or later." PKG_MOKOSUITECROSS_MIGRATION_DETECTED="Perfect Publisher Pro detected! Navigate to Components → MokoSuiteCross → Dashboard to migrate your settings." diff --git a/source/packages/com_mokosuitecross/config.xml b/source/packages/com_mokosuitecross/config.xml index 0ee1c826..c93837fd 100644 --- a/source/packages/com_mokosuitecross/config.xml +++ b/source/packages/com_mokosuitecross/config.xml @@ -120,6 +120,42 @@ /> +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+
com_mokosuitecross - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitecross/site/language/en-GB/com_mokosuitecross.ini b/source/packages/com_mokosuitecross/site/language/en-GB/com_mokosuitecross.ini index 2ffcc30c..b69ad850 100644 --- a/source/packages/com_mokosuitecross/site/language/en-GB/com_mokosuitecross.ini +++ b/source/packages/com_mokosuitecross/site/language/en-GB/com_mokosuitecross.ini @@ -1,5 +1,14 @@ -; MokoSuiteCross — Site Frontend Language File +; MokoSuiteCross -- Site Frontend Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later COM_MOKOSUITECROSS="MokoSuiteCross" +COM_MOKOSUITECROSS_POSTS_LIST_TITLE="Cross-Posted Content" +COM_MOKOSUITECROSS_POST_DETAIL_TITLE="Cross-Post History" +COM_MOKOSUITECROSS_COLUMN_ARTICLE="Article" +COM_MOKOSUITECROSS_COLUMN_PLATFORMS="Platforms" +COM_MOKOSUITECROSS_COLUMN_LAST_POSTED="Last Posted" +COM_MOKOSUITECROSS_COLUMN_STATUS="Status" +COM_MOKOSUITECROSS_COLUMN_POSTED_DATE="Posted Date" +COM_MOKOSUITECROSS_COLUMN_LINK="Platform Link" +COM_MOKOSUITECROSS_NO_POSTS="No cross-posted content found." diff --git a/source/packages/com_mokosuitecross/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitecross/site/src/Controller/DisplayController.php index be00f933..9c9415f2 100644 --- a/source/packages/com_mokosuitecross/site/src/Controller/DisplayController.php +++ b/source/packages/com_mokosuitecross/site/src/Controller/DisplayController.php @@ -17,5 +17,5 @@ use Joomla\CMS\MVC\Controller\BaseController; class DisplayController extends BaseController { - protected $default_view = 'post'; + protected $default_view = 'posts'; } diff --git a/source/packages/com_mokosuitecross/site/src/Model/PostModel.php b/source/packages/com_mokosuitecross/site/src/Model/PostModel.php new file mode 100644 index 00000000..b709d82c --- /dev/null +++ b/source/packages/com_mokosuitecross/site/src/Model/PostModel.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Site\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class PostModel extends BaseDatabaseModel +{ + public function getArticle(int $articleId): ?object + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + + $query = $db->getQuery(true) + ->select('a.id, a.title, a.alias, a.catid, a.access') + ->from($db->quoteName('#__content', 'a')) + ->where('a.id = ' . (int) $articleId) + ->where('a.state = 1'); + + $groups = $user->getAuthorisedViewLevels(); + $query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')'); + + $db->setQuery($query); + + return $db->loadObject() ?: null; + } + + public function getPosts(int $articleId): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([ + 'p.id', + 'p.status', + 'p.platform_post_id', + 'p.posted_at', + 'p.error_message', + 'p.created', + 's.title AS service_title', + 's.service_type', + ]) + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id') + ->where('p.article_id = ' . (int) $articleId) + ->order('p.created DESC'); + + $db->setQuery($query, 0, 50); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitecross/site/src/Model/PostsModel.php b/source/packages/com_mokosuitecross/site/src/Model/PostsModel.php new file mode 100644 index 00000000..eaef7684 --- /dev/null +++ b/source/packages/com_mokosuitecross/site/src/Model/PostsModel.php @@ -0,0 +1,51 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Site\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\ListModel; + +class PostsModel extends ListModel +{ + protected function getListQuery() + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $query = $db->getQuery(true); + + $query->select([ + 'a.id AS article_id', + 'a.title AS article_title', + 'a.alias AS article_alias', + 'a.catid', + 'MAX(p.posted_at) AS last_posted', + 'COUNT(p.id) AS post_count', + 'GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR \',\') AS service_types', + ]) + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__content', 'a') . ' ON a.id = p.article_id') + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id') + ->where('p.status = ' . $db->quote('posted')) + ->where('a.state = 1'); + + // Access filtering + $groups = $user->getAuthorisedViewLevels(); + $query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')'); + + $query->group('a.id, a.title, a.alias, a.catid') + ->order('last_posted DESC'); + + return $query; + } +} diff --git a/source/packages/com_mokosuitecross/site/src/View/Post/HtmlView.php b/source/packages/com_mokosuitecross/site/src/View/Post/HtmlView.php new file mode 100644 index 00000000..b4729330 --- /dev/null +++ b/source/packages/com_mokosuitecross/site/src/View/Post/HtmlView.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Site\View\Post; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; + +class HtmlView extends BaseHtmlView +{ + protected $article; + protected $posts; + + public function display($tpl = null): void + { + $articleId = Factory::getApplication()->getInput()->getInt('id', 0); + $model = $this->getModel(); + $this->article = $model->getArticle($articleId); + $this->posts = $model->getPosts($articleId); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitecross/site/src/View/Posts/HtmlView.php b/source/packages/com_mokosuitecross/site/src/View/Posts/HtmlView.php new file mode 100644 index 00000000..ac262d5b --- /dev/null +++ b/source/packages/com_mokosuitecross/site/src/View/Posts/HtmlView.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Site\View\Posts; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; + +class HtmlView extends BaseHtmlView +{ + protected $items; + protected $pagination; + + public function display($tpl = null): void + { + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitecross/site/tmpl/post/default.php b/source/packages/com_mokosuitecross/site/tmpl/post/default.php new file mode 100644 index 00000000..5b95ce9a --- /dev/null +++ b/source/packages/com_mokosuitecross/site/tmpl/post/default.php @@ -0,0 +1,84 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +$statusClasses = [ + 'posted' => 'bg-success', + 'failed' => 'bg-danger', + 'permanently_failed' => 'bg-danger', + 'queued' => 'bg-warning text-dark', + 'posting' => 'bg-info', + 'scheduled' => 'bg-primary', + 'deleted' => 'bg-secondary', + 'cancelled' => 'bg-secondary', +]; + +?> +
+ article) : ?> +
+ +
+ +

+

+ escape($this->article->title); ?> +

+ + posts)) : ?> +
+ +
+ + + + + + + + + + + + posts as $post) : ?> + + + + + + + + +
+ escape($post->service_type); ?> + escape($post->service_title); ?> + + + escape(ucfirst($post->status)); ?> + + posted_at ? $this->escape($post->posted_at) : $this->escape($post->created); ?> + platform_post_id)) : ?> + escape($post->platform_post_id); ?> + + — + +
+ + + + ← + + +
diff --git a/source/packages/com_mokosuitecross/site/tmpl/posts/default.php b/source/packages/com_mokosuitecross/site/tmpl/posts/default.php new file mode 100644 index 00000000..2acd7b40 --- /dev/null +++ b/source/packages/com_mokosuitecross/site/tmpl/posts/default.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +?> +
+

+ + items)) : ?> +
+ +
+ + + + + + + + + + + items as $item) : ?> + + + + + + + +
+ + escape($item->article_title); ?> + + + service_types ?? ''); + foreach ($types as $type) : + $type = trim($type); + if (empty($type)) continue; + ?> + escape($type); ?> + + + last_posted ? $this->escape($item->last_posted) : '—'; ?> +
+ + pagination->pagesTotal > 1) : ?> +
+ pagination->getListFooter(); ?> +
+ + +
diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.05.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.05.sql new file mode 100644 index 00000000..778cff01 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.05.sql @@ -0,0 +1 @@ +/* 01.08.05 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.07.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.07.sql new file mode 100644 index 00000000..9a612b25 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.07.sql @@ -0,0 +1 @@ +/* 01.08.07 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.08.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.08.sql new file mode 100644 index 00000000..4e930cd5 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.08.sql @@ -0,0 +1 @@ +/* 01.08.08 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.09.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.09.sql new file mode 100644 index 00000000..3ffc9f0b --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.09.sql @@ -0,0 +1 @@ +/* 01.08.09 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.10.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.10.sql new file mode 100644 index 00000000..58e4e5a2 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.10.sql @@ -0,0 +1 @@ +/* 01.08.10 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.11.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.11.sql new file mode 100644 index 00000000..8657ee87 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.11.sql @@ -0,0 +1 @@ +/* 01.08.11 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.12.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.12.sql new file mode 100644 index 00000000..e273280a --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.12.sql @@ -0,0 +1 @@ +/* 01.08.12 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.13.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.13.sql new file mode 100644 index 00000000..5c6d6f18 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.13.sql @@ -0,0 +1 @@ +/* 01.08.13 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.14.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.14.sql new file mode 100644 index 00000000..bd894a1f --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.14.sql @@ -0,0 +1 @@ +/* 01.08.14 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.15.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.15.sql new file mode 100644 index 00000000..ac8ed933 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.15.sql @@ -0,0 +1 @@ +/* 01.08.15 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.16.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.16.sql new file mode 100644 index 00000000..999077b0 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.16.sql @@ -0,0 +1 @@ +/* 01.08.16 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.17.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.17.sql new file mode 100644 index 00000000..e5cea330 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.17.sql @@ -0,0 +1 @@ +/* 01.08.17 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.19.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.19.sql new file mode 100644 index 00000000..f5578261 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.19.sql @@ -0,0 +1 @@ +/* 01.08.19 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.20.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.20.sql new file mode 100644 index 00000000..a1a533f8 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.20.sql @@ -0,0 +1 @@ +/* 01.08.20 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.21.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.21.sql new file mode 100644 index 00000000..a536217a --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.21.sql @@ -0,0 +1 @@ +/* 01.08.21 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.22.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.22.sql new file mode 100644 index 00000000..ecd41b01 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.22.sql @@ -0,0 +1 @@ +/* 01.08.22 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.23.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.23.sql new file mode 100644 index 00000000..f11054f7 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.23.sql @@ -0,0 +1 @@ +/* 01.08.23 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.24.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.24.sql new file mode 100644 index 00000000..46fed4d4 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.24.sql @@ -0,0 +1 @@ +/* 01.08.24 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.25.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.25.sql new file mode 100644 index 00000000..70327ee7 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.25.sql @@ -0,0 +1 @@ +/* 01.08.25 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.26.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.26.sql new file mode 100644 index 00000000..527ddd8b --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.26.sql @@ -0,0 +1 @@ +/* 01.08.26 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.27.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.27.sql new file mode 100644 index 00000000..8baf9791 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.27.sql @@ -0,0 +1 @@ +/* 01.08.27 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.28.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.28.sql new file mode 100644 index 00000000..65f87444 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.28.sql @@ -0,0 +1 @@ +/* 01.08.28 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.29.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.29.sql new file mode 100644 index 00000000..1ecdd417 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.29.sql @@ -0,0 +1 @@ +/* 01.08.29 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.30.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.30.sql new file mode 100644 index 00000000..df57e798 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.30.sql @@ -0,0 +1 @@ +/* 01.08.30 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.31.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.31.sql new file mode 100644 index 00000000..7da9aac7 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.31.sql @@ -0,0 +1 @@ +/* 01.08.31 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.32.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.32.sql new file mode 100644 index 00000000..9fc7f718 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.32.sql @@ -0,0 +1 @@ +/* 01.08.32 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.33.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.33.sql new file mode 100644 index 00000000..1e5d9d6f --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.33.sql @@ -0,0 +1 @@ +/* 01.08.33 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.34.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.34.sql new file mode 100644 index 00000000..9c2235aa --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.34.sql @@ -0,0 +1 @@ +/* 01.08.34 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.35.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.35.sql new file mode 100644 index 00000000..d1969e9f --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.35.sql @@ -0,0 +1 @@ +/* 01.08.35 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.36.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.36.sql new file mode 100644 index 00000000..178aafaa --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.36.sql @@ -0,0 +1 @@ +/* 01.08.36 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.37.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.37.sql new file mode 100644 index 00000000..fdddaa0d --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.37.sql @@ -0,0 +1 @@ +/* 01.08.37 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.38.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.38.sql new file mode 100644 index 00000000..9fbca277 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.38.sql @@ -0,0 +1 @@ +/* 01.08.38 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.39.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.39.sql new file mode 100644 index 00000000..1294ea97 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.39.sql @@ -0,0 +1 @@ +/* 01.08.39 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.40.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.40.sql new file mode 100644 index 00000000..e58fdb9b --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.40.sql @@ -0,0 +1 @@ +/* 01.08.40 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.41.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.41.sql new file mode 100644 index 00000000..d06172d4 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.41.sql @@ -0,0 +1 @@ +/* 01.08.41 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.43.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.43.sql new file mode 100644 index 00000000..e6e834b6 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.43.sql @@ -0,0 +1 @@ +/* 01.08.43 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.44.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.44.sql new file mode 100644 index 00000000..2c0454dd --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.44.sql @@ -0,0 +1 @@ +/* 01.08.44 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.45.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.45.sql new file mode 100644 index 00000000..14748c2a --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.45.sql @@ -0,0 +1 @@ +/* 01.08.45 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.46.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.46.sql new file mode 100644 index 00000000..955aeecd --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.46.sql @@ -0,0 +1 @@ +/* 01.08.46 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.47.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.47.sql new file mode 100644 index 00000000..ae491665 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.47.sql @@ -0,0 +1 @@ +/* 01.08.47 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.49.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.49.sql new file mode 100644 index 00000000..75058ee9 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.49.sql @@ -0,0 +1 @@ +/* 01.08.49 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.50.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.50.sql new file mode 100644 index 00000000..4f18a6f2 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.50.sql @@ -0,0 +1 @@ +/* 01.08.50 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.51.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.51.sql new file mode 100644 index 00000000..f85a2972 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.51.sql @@ -0,0 +1 @@ +/* 01.08.51 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.52.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.52.sql new file mode 100644 index 00000000..059d891b --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.52.sql @@ -0,0 +1 @@ +/* 01.08.52 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.53.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.53.sql new file mode 100644 index 00000000..dcb58637 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.53.sql @@ -0,0 +1 @@ +/* 01.08.53 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql new file mode 100644 index 00000000..ac182a97 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql @@ -0,0 +1 @@ +/* 01.08.54 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.55.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.55.sql new file mode 100644 index 00000000..e4e2af7d --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.55.sql @@ -0,0 +1 @@ +/* 01.08.55 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.56.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.56.sql new file mode 100644 index 00000000..856f6c52 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.56.sql @@ -0,0 +1 @@ +/* 01.08.56 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.57.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.57.sql new file mode 100644 index 00000000..f0c936a7 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.57.sql @@ -0,0 +1 @@ +/* 01.08.57 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.58.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.58.sql new file mode 100644 index 00000000..cdcef69b --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.58.sql @@ -0,0 +1 @@ +/* 01.08.58 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.61.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.61.sql new file mode 100644 index 00000000..d4c648e3 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.61.sql @@ -0,0 +1 @@ +/* 01.08.61 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/src/Controller/AiController.php b/source/packages/com_mokosuitecross/src/Controller/AiController.php new file mode 100644 index 00000000..0905a0ca --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/AiController.php @@ -0,0 +1,100 @@ + + * @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(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php new file mode 100644 index 00000000..fce9f59b --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\BaseController; + +class AnalyticsController extends BaseController +{ + public function display($cachable = false, $urlparams = []): static + { + return parent::display($cachable, $urlparams); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/CalendarController.php b/source/packages/com_mokosuitecross/src/Controller/CalendarController.php new file mode 100644 index 00000000..e36b117e --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/CalendarController.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\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); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/PreviewController.php b/source/packages/com_mokosuitecross/src/Controller/PreviewController.php new file mode 100644 index 00000000..eb0b05b3 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/PreviewController.php @@ -0,0 +1,101 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\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\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\CrossPostDispatcher; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\PreviewHelper; + +class PreviewController extends BaseController +{ + public function render(): void + { + if (!Session::checkToken('get')) { + echo json_encode(['error' => 'Invalid token']); + $this->app->close(); + + return; + } + + $user = $this->app->getIdentity(); + + if (!$user->authorise('core.manage', 'com_mokosuitecross') + && !$user->authorise('core.edit', 'com_content') + && !$user->authorise('core.edit.own', 'com_content')) { + echo json_encode(['error' => 'Permission denied']); + $this->app->close(); + + return; + } + + $articleId = $this->input->getInt('article_id', 0); + $platform = $this->input->getCmd('platform', 'twitter'); + + if ($articleId < 1) { + echo json_encode(['error' => 'Missing article ID']); + $this->app->close(); + + return; + } + + $db = Factory::getDbo(); + + $groups = $user->getAuthorisedViewLevels(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = :id') + ->where($db->quoteName('access') . ' IN (' . implode(',', array_map('intval', $groups)) . ')') + ->bind(':id', $articleId, \Joomla\Database\ParameterType::INTEGER); + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + echo json_encode(['error' => 'Article not found']); + $this->app->close(); + + return; + } + + $meta = CrossPostDispatcher::buildArticleMeta($article); + + $title = $meta['{title}'] ?? ''; + $text = $meta['{introtext}'] ?? ''; + $url = $meta['{url}'] ?? ''; + $imageUrl = $meta['{image}'] ?? ''; + $authorName = $meta['{author}'] ?? ''; + + $supportedPlatforms = PreviewHelper::getSupportedPlatforms(); + $html = ''; + + if ($platform === 'all') { + foreach ($supportedPlatforms as $p) { + $html .= '
' + . '
' . htmlspecialchars(ucfirst($p)) . '
' + . PreviewHelper::render($p, $title, $text, $url, $imageUrl, $authorName) + . '
'; + } + } else { + $html = PreviewHelper::render($platform, $title, $text, $url, $imageUrl, $authorName); + } + + $this->app->setHeader('Content-Type', 'text/html; charset=utf-8'); + echo $html; + $this->app->close(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php b/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php new file mode 100644 index 00000000..36462869 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php @@ -0,0 +1,98 @@ + + * @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(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php b/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php new file mode 100644 index 00000000..c5201d51 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php @@ -0,0 +1,196 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\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 << 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), + ]; + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php new file mode 100644 index 00000000..a2b324ff --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php @@ -0,0 +1,160 @@ + + * @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'; + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php index 71b50927..b1391fd4 100644 --- a/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php +++ b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php @@ -477,12 +477,16 @@ class CrossPostDispatcher $url = $url . $separator . http_build_query($utmParams); } + // Link shortening (#159) — shorten the final URL (with UTM if enabled) + $urlShort = LinkShortenerHelper::shorten($url); + return [ '{title}' => $titleText, '{introtext}' => $introStripped, '{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)), '{url}' => $url, '{url_raw}' => $urlRaw, + '{url_short}' => $urlShort, '{image}' => $introImage, '{category}' => $categoryName, '{author}' => $authorName, diff --git a/source/packages/com_mokosuitecross/src/Helper/LinkShortenerHelper.php b/source/packages/com_mokosuitecross/src/Helper/LinkShortenerHelper.php new file mode 100644 index 00000000..9850e4f9 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/LinkShortenerHelper.php @@ -0,0 +1,172 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; + +/** + * Shortens URLs via Bitly, Rebrandly, or YOURLS. + * + * Returns the original URL on any failure so cross-posts are never broken. + */ +class LinkShortenerHelper +{ + /** + * Shorten a URL using the configured provider. + * + * @param string $url The URL to shorten + * + * @return string Shortened URL, or the original on failure/disabled + */ + public static function shorten(string $url): string + { + $params = ComponentHelper::getParams('com_mokosuitecross'); + $provider = $params->get('link_shortener', 'none'); + + if ($provider === 'none' || empty($url)) { + return $url; + } + + $apiKey = $params->get('link_shortener_api_key', ''); + + switch ($provider) { + case 'bitly': + return self::shortenWithBitly($url, $apiKey); + + case 'rebrandly': + return self::shortenWithRebrandly($url, $apiKey); + + case 'yourls': + $apiUrl = $params->get('link_shortener_yourls_url', ''); + $token = $params->get('link_shortener_yourls_token', ''); + return self::shortenWithYourls($url, $apiUrl, $token); + + default: + return $url; + } + } + + /** + * Shorten via Bitly API v4. + */ + public static function shortenWithBitly(string $url, string $apiKey): string + { + if (empty($apiKey)) { + return $url; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://api-ssl.bitly.com/v4/shorten', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['long_url' => $url]), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $apiKey, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode < 200 || $httpCode >= 300) { + return $url; + } + + $data = json_decode($response, true); + + return $data['link'] ?? $url; + } + + /** + * Shorten via Rebrandly API. + */ + public static function shortenWithRebrandly(string $url, string $apiKey, string $workspace = ''): string + { + if (empty($apiKey)) { + return $url; + } + + $headers = [ + 'apikey: ' . $apiKey, + 'Content-Type: application/json', + ]; + + if (!empty($workspace)) { + $headers[] = 'workspace: ' . $workspace; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://api.rebrandly.com/v1/links', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['destination' => $url]), + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode < 200 || $httpCode >= 300) { + return $url; + } + + $data = json_decode($response, true); + $short = $data['shortUrl'] ?? ''; + + return !empty($short) ? 'https://' . ltrim($short, 'https://') : $url; + } + + /** + * Shorten via YOURLS API (self-hosted). + */ + public static function shortenWithYourls(string $url, string $apiUrl, string $signatureToken): string + { + if (empty($apiUrl) || empty($signatureToken)) { + return $url; + } + + $endpoint = rtrim($apiUrl, '/') . '?' . http_build_query([ + 'action' => 'shorturl', + 'format' => 'json', + 'signature' => $signatureToken, + 'url' => $url, + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode < 200 || $httpCode >= 300) { + return $url; + } + + $data = json_decode($response, true); + + return $data['shorturl'] ?? $url; + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php index 04a29037..84b66d4d 100644 --- a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php +++ b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php @@ -41,6 +41,8 @@ class MokoSuiteCrossHelper 'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES', 'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES', 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', + 'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR', + 'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS', ]; // Joomla 5+ toolbar submenu diff --git a/source/packages/com_mokosuitecross/src/Helper/PreviewHelper.php b/source/packages/com_mokosuitecross/src/Helper/PreviewHelper.php new file mode 100644 index 00000000..fc0fd0ad --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/PreviewHelper.php @@ -0,0 +1,207 @@ + + * @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 PreviewHelper +{ + public static function render(string $platform, string $title, string $text, string $url, string $imageUrl = '', string $authorName = ''): string + { + $title = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + $url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); + $authorName = htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8'); + + $imageHtml = ''; + + if (!empty($imageUrl)) { + $imageUrl = htmlspecialchars($imageUrl, ENT_QUOTES, 'UTF-8'); + $imageHtml = ''; + } + + return match ($platform) { + 'twitter' => self::renderTwitter($title, $text, $url, $imageHtml, $authorName), + 'facebook' => self::renderFacebook($title, $text, $url, $imageHtml, $authorName), + 'mastodon' => self::renderMastodon($title, $text, $url, $imageHtml, $authorName), + 'linkedin' => self::renderLinkedIn($title, $text, $url, $imageHtml, $authorName), + 'bluesky' => self::renderBluesky($title, $text, $url, $imageHtml, $authorName), + 'telegram' => self::renderTelegram($title, $text, $url, $imageHtml), + default => self::renderGeneric($platform, $title, $text, $url, $imageHtml), + }; + } + + public static function getSupportedPlatforms(): array + { + return ['twitter', 'facebook', 'mastodon', 'linkedin', 'bluesky', 'telegram']; + } + + private static function renderTwitter(string $title, string $text, string $url, string $imageHtml, string $author): string + { + $displayText = !empty($text) ? $text : $title; + $charCount = mb_strlen(strip_tags($displayText)); + + return << +
+
X
+
+
{$author}
+
@username
+
+
+
{$displayText}
+ {$imageHtml} +
+
yoursite.com
+
{$title}
+
+
{$charCount}/280
+ +HTML; + } + + private static function renderFacebook(string $title, string $text, string $url, string $imageHtml, string $author): string + { + $displayText = !empty($text) ? $text : $title; + + return << +
+
+
f
+
+
{$author}
+
Just now
+
+
+
{$displayText}
+
+ {$imageHtml} +
+
yoursite.com
+
{$title}
+
+ +HTML; + } + + private static function renderMastodon(string $title, string $text, string $url, string $imageHtml, string $author): string + { + $displayText = !empty($text) ? $text : $title; + $charCount = mb_strlen(strip_tags($displayText)); + + return << +
+
M
+
+
{$author}
+
@user@mastodon.social
+
+
+
{$displayText}
+ {$imageHtml} +
+
{$title}
+
yoursite.com
+
+
{$charCount}/500
+ +HTML; + } + + private static function renderLinkedIn(string $title, string $text, string $url, string $imageHtml, string $author): string + { + $displayText = !empty($text) ? $text : $title; + + return << +
+
+
in
+
+
{$author}
+
Just now
+
+
+
{$displayText}
+
+ {$imageHtml} +
+
{$title}
+
yoursite.com
+
+ +HTML; + } + + private static function renderBluesky(string $title, string $text, string $url, string $imageHtml, string $author): string + { + $displayText = !empty($text) ? $text : $title; + $charCount = mb_strlen(strip_tags($displayText)); + + return << +
+
B
+
+
{$author}
+
@user.bsky.social
+
+
+
{$displayText}
+ {$imageHtml} +
+
{$title}
+
yoursite.com
+
+
{$charCount}/300
+ +HTML; + } + + private static function renderTelegram(string $title, string $text, string $url, string $imageHtml): string + { + $displayText = !empty($text) ? $text : $title; + + return << + {$imageHtml} +
{$displayText}
+
+
{$title}
+
yoursite.com
+
+
Just now
+ +HTML; + } + + private static function renderGeneric(string $platform, string $title, string $text, string $url, string $imageHtml): string + { + $platformLabel = htmlspecialchars(ucfirst($platform), ENT_QUOTES, 'UTF-8'); + $displayText = !empty($text) ? $text : $title; + + return << +
{$platformLabel}
+
{$displayText}
+ {$imageHtml} +
+
{$title}
+
yoursite.com
+
+ +HTML; + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php new file mode 100644 index 00000000..701f0395 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php @@ -0,0 +1,207 @@ + + * @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)), + ]; + } +} diff --git a/source/packages/com_mokosuitecross/src/Model/AnalyticsModel.php b/source/packages/com_mokosuitecross/src/Model/AnalyticsModel.php new file mode 100644 index 00000000..0cad8a80 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Model/AnalyticsModel.php @@ -0,0 +1,169 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class AnalyticsModel extends BaseDatabaseModel +{ + public function getHeatmap(int $days = 90, ?int $serviceId = null): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s'); + + $query = $db->getQuery(true) + ->select([ + 'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow', + 'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day', + 'COUNT(*) AS total', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success', + ]) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('posted_at') . ' IS NOT NULL') + ->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)) + ->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')') + ->order('dow ASC, hour_of_day ASC'); + + if ($serviceId !== null && $serviceId > 0) { + $query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId); + } + + $db->setQuery($query); + $rows = $db->loadAssocList() ?: []; + + $grid = []; + + for ($d = 1; $d <= 7; $d++) { + for ($h = 0; $h < 24; $h++) { + $grid[$d][$h] = ['total' => 0, 'success' => 0, 'rate' => 0]; + } + } + + foreach ($rows as $row) { + $d = (int) $row['dow']; + $h = (int) $row['hour_of_day']; + $grid[$d][$h] = [ + 'total' => (int) $row['total'], + 'success' => (int) $row['success'], + 'rate' => (int) $row['total'] > 0 + ? round(((int) $row['success'] / (int) $row['total']) * 100) + : 0, + ]; + } + + return $grid; + } + + public function getBestTimes(int $days = 90, ?int $serviceId = null, int $limit = 5): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s'); + + $query = $db->getQuery(true) + ->select([ + 'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow', + 'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day', + 'COUNT(*) AS total', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success', + ]) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('posted_at') . ' IS NOT NULL') + ->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)) + ->group('DAYOFWEEK(' . $db->quoteName('posted_at') . '), HOUR(' . $db->quoteName('posted_at') . ')') + ->having('COUNT(*) >= 3') + ->order('success DESC, total DESC'); + + if ($serviceId !== null && $serviceId > 0) { + $query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId); + } + + $db->setQuery($query, 0, $limit); + + return $db->loadAssocList() ?: []; + } + + public function getHourlyDistribution(int $days = 90, ?int $serviceId = null): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s'); + + $query = $db->getQuery(true) + ->select([ + 'HOUR(' . $db->quoteName('posted_at') . ') AS hour_of_day', + 'COUNT(*) AS total', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed', + ]) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('posted_at') . ' IS NOT NULL') + ->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)) + ->group('HOUR(' . $db->quoteName('posted_at') . ')') + ->order('hour_of_day ASC'); + + if ($serviceId !== null && $serviceId > 0) { + $query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId); + } + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + public function getDayOfWeekDistribution(int $days = 90, ?int $serviceId = null): array + { + $db = $this->getDatabase(); + + $cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d H:i:s'); + + $query = $db->getQuery(true) + ->select([ + 'DAYOFWEEK(' . $db->quoteName('posted_at') . ') AS dow', + 'COUNT(*) AS total', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success', + 'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed', + ]) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('posted_at') . ' IS NOT NULL') + ->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)) + ->group('DAYOFWEEK(' . $db->quoteName('posted_at') . ')') + ->order('dow ASC'); + + if ($serviceId !== null && $serviceId > 0) { + $query->where($db->quoteName('service_id') . ' = ' . (int) $serviceId); + } + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + public function getServices(): array + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('title'), $db->quoteName('service_type')]) + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('title') . ' ASC'); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } +} diff --git a/source/packages/com_mokosuitecross/src/Model/CalendarModel.php b/source/packages/com_mokosuitecross/src/Model/CalendarModel.php new file mode 100644 index 00000000..1ecd02f2 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Model/CalendarModel.php @@ -0,0 +1,67 @@ + + * @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; + } +} diff --git a/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php new file mode 100644 index 00000000..a79d2830 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php @@ -0,0 +1,65 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\View\Analytics; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper; + +class HtmlView extends BaseHtmlView +{ + public $heatmap; + public $bestTimes; + public $hourlyDistribution; + public $dayDistribution; + public $services; + public $serviceId; + public $period; + + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoSuiteCross\Administrator\Model\AnalyticsModel $model */ + $model = $this->getModel(); + + $input = Factory::getApplication()->input; + $this->period = $input->getInt('period', 90); + $this->serviceId = $input->getInt('service_id', 0); + + $validPeriods = [7, 30, 90, 180, 365]; + + if (!\in_array($this->period, $validPeriods, true)) { + $this->period = 90; + } + + $sid = $this->serviceId > 0 ? $this->serviceId : null; + + $this->heatmap = $model->getHeatmap($this->period, $sid); + $this->bestTimes = $model->getBestTimes($this->period, $sid); + $this->hourlyDistribution = $model->getHourlyDistribution($this->period, $sid); + $this->dayDistribution = $model->getDayOfWeekDistribution($this->period, $sid); + $this->services = $model->getServices(); + + $this->addToolbar(); + + MokoSuiteCrossHelper::addSubmenu('analytics'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('MokoSuiteCross -- Analytics', 'chart'); + } +} diff --git a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php new file mode 100644 index 00000000..58706228 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php @@ -0,0 +1,65 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\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'); + } + } +} diff --git a/source/packages/com_mokosuitecross/tmpl/analytics/default.php b/source/packages/com_mokosuitecross/tmpl/analytics/default.php new file mode 100644 index 00000000..9fbe6fed --- /dev/null +++ b/source/packages/com_mokosuitecross/tmpl/analytics/default.php @@ -0,0 +1,240 @@ + + * @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'), +]; +?> +
+ + +
+
+ + +
+
+ + +
+
+
+ +bestTimes)) : ?> +
+
+
+
+
+
+ bestTimes as $bt) : + $rate = (int) $bt['total'] > 0 ? round(((int) $bt['success'] / (int) $bt['total']) * 100) : 0; + ?> +
+
+
+
+
+ +
+ % +
+
+ +
+
+
+ +
+ +
+ + +
+
+
+
+
+ + + + + + + + + + + heatmap as $dayData) { + foreach ($dayData as $cell) { + if ($cell['total'] > $maxTotal) { + $maxTotal = $cell['total']; + } + } + } + + foreach ($this->heatmap as $dow => $hours) : ?> + + + $cell) : + $intensity = $maxTotal > 0 ? $cell['total'] / $maxTotal : 0; + $r = 255; + $g = 255; + $b = 255; + + if ($cell['total'] > 0) { + $rate = $cell['rate']; + + if ($rate >= 80) { + $r = (int) (255 - (155 * $intensity)); + $g = (int) (255 - (100 * $intensity)); + $b = (int) (255 - (155 * $intensity)); + } elseif ($rate >= 50) { + $r = (int) (255 - (50 * $intensity)); + $g = (int) (255 - (50 * $intensity)); + $b = (int) (255 - (200 * $intensity)); + } else { + $r = (int) (255 - (35 * $intensity)); + $g = (int) (255 - (200 * $intensity)); + $b = (int) (255 - (200 * $intensity)); + } + } + ?> + + + + + +
); cursor: default;" + title=""> + 0) : ?> + + +
+
+ + + + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + diff --git a/source/packages/com_mokosuitecross/tmpl/calendar/default.php b/source/packages/com_mokosuitecross/tmpl/calendar/default.php new file mode 100644 index 00000000..dc0e2187 --- /dev/null +++ b/source/packages/com_mokosuitecross/tmpl/calendar/default.php @@ -0,0 +1,129 @@ + + * @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', + }; +}; +?> + + + +
+ + + + + + + + + + + + + + + + + + $daysInMonth) : ?> + + + + + + + +
   +
+ + + + +
+ + + service_type)); ?>: + article_title, 0, 20)); ?> + + +
+
diff --git a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php index 13be5781..4ac74e7c 100644 --- a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php @@ -282,6 +282,10 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); class="list-group-item list-group-item-action"> + + + diff --git a/source/packages/plg_content_mokosuitecross/language/en-GB/plg_content_mokosuitecross.ini b/source/packages/plg_content_mokosuitecross/language/en-GB/plg_content_mokosuitecross.ini index 50ff4171..990c1de2 100644 --- a/source/packages/plg_content_mokosuitecross/language/en-GB/plg_content_mokosuitecross.ini +++ b/source/packages/plg_content_mokosuitecross/language/en-GB/plg_content_mokosuitecross.ini @@ -31,3 +31,6 @@ PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_CUSTOM="Custom Image" PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_NONE="No Image" PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE="Custom Share Image" PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE_DESC="Select an image from the media manager to use for cross-posting." + +PLG_CONTENT_MOKOSUITECROSS_FIELDSET_PREVIEW="Social Preview" +PLG_CONTENT_MOKOSUITECROSS_PREVIEW="Preview Post" diff --git a/source/packages/plg_content_mokosuitecross/mokosuitecross.xml b/source/packages/plg_content_mokosuitecross/mokosuitecross.xml index f8b6311f..654e6843 100644 --- a/source/packages/plg_content_mokosuitecross/mokosuitecross.xml +++ b/source/packages/plg_content_mokosuitecross/mokosuitecross.xml @@ -1,7 +1,7 @@ Content - MokoSuiteCross - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php index 59df6e7e..57d8eb15 100644 --- a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php +++ b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php @@ -19,6 +19,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Form\Form; use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Session\Session; use Joomla\CMS\Uri\Uri; use Joomla\Component\MokoSuiteCross\Administrator\Helper\CrossPostDispatcher; use Joomla\Event\SubscriberInterface; @@ -211,8 +212,53 @@ XML; $form->load($xml); - // Cross-post history panel for existing articles + // AI Generate button for the Share Content panel $articleId = Factory::getApplication()->input->getInt('id', 0); + $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 = '
' + . '' + . '' + . '
' + . ''; + + $aiXml = ' +
+ +
'; + $form->load($aiXml); + $form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs'); + } + + // Cross-post history panel for existing articles if ($articleId > 0) { $query = $db->getQuery(true) @@ -265,30 +311,57 @@ XML; $form->load($historyXml); $form->setFieldAttribute('mokosuitecross_history', 'description', $historyHtml, 'attribs'); } + + // Social Preview panel (#156) + $token = Session::getFormToken(); + $previewUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=preview.render&format=raw&article_id=' . $articleId . '&' . $token . '=1'; + + $previewButtonHtml = '
' + . '
' + . ' ' + . '' + . '
' + . '
' + . '
' + . ''; + + $previewXml = ' +
+ +
'; + $form->load($previewXml); + $form->setFieldAttribute('mokosuitecross_preview_panel', 'description', $previewButtonHtml, 'attribs'); } } /** * Add cross-post status badges before article content in admin. - * - * Joomla 5/6 compatible — accepts both BeforeDisplayEvent and legacy parameters. */ - public function onContentBeforeDisplay($event): string + public function onContentBeforeDisplay(\Joomla\CMS\Event\Content\BeforeDisplayEvent $event): string { - // Joomla 5/6 compatibility - if ($event instanceof \Joomla\CMS\Event\Content\BeforeDisplayEvent) { - $context = $event->getContext(); - $article = $event->getItem(); - } elseif (is_string($event)) { - $context = $event; - $article = func_get_arg(1); - } else { - return ''; - } - - if ($context !== 'com_content.article') { - return ''; - } + $context = $event->getContext(); + $article = $event->getItem(); $app = $this->getApplication(); @@ -330,26 +403,18 @@ XML; /** * Dispatch cross-post when an article is saved and published. - * - * Joomla 5/6 compatible — accepts both AfterSaveEvent and legacy parameters. */ - public function onContentAfterSave($event): void + public function onContentAfterSave(\Joomla\CMS\Event\Content\AfterSaveEvent $event): void { - // Joomla 5/6 compatibility - if ($event instanceof \Joomla\CMS\Event\Content\AfterSaveEvent) { - $context = $event->getContext(); - $article = $event->getItem(); - $isNew = $event->getIsNew(); - } else { - $context = $event; - $article = func_get_arg(1); - $isNew = func_get_arg(2); - } + $context = $event->getContext(); if ($context !== 'com_content.article') { return; } + $article = $event->getItem(); + $isNew = $event->getIsNew(); + if ((int) ($article->state ?? 0) !== 1) { return; } @@ -375,25 +440,18 @@ XML; /** * Dispatch cross-post when article state changes to published. - * - * Joomla 5/6 compatible — accepts both ContentChangeStateEvent and legacy parameters. */ - public function onContentChangeState($event): void + public function onContentChangeState(\Joomla\CMS\Event\Content\ContentChangeStateEvent $event): void { - if ($event instanceof \Joomla\CMS\Event\Content\ContentChangeStateEvent) { - $context = $event->getContext(); - $pks = $event->getPks(); - $value = $event->getValue(); - } else { - $context = $event; - $pks = func_get_arg(1); - $value = func_get_arg(2); - } + $context = $event->getContext(); if ($context !== 'com_content.article') { return; } + $pks = $event->getPks(); + $value = $event->getValue(); + $params = ComponentHelper::getParams('com_mokosuitecross'); // Unpublish/trash: delete from platforms if configured diff --git a/source/packages/plg_mokosuitecross_activitypub/activitypub.xml b/source/packages/plg_mokosuitecross_activitypub/activitypub.xml index 04a1e730..7758e370 100644 --- a/source/packages/plg_mokosuitecross_activitypub/activitypub.xml +++ b/source/packages/plg_mokosuitecross_activitypub/activitypub.xml @@ -1,7 +1,7 @@ MokoSuiteCross - ActivityPub (Fediverse) - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_blogger/blogger.xml b/source/packages/plg_mokosuitecross_blogger/blogger.xml index 5fbff116..fa6c8c56 100644 --- a/source/packages/plg_mokosuitecross_blogger/blogger.xml +++ b/source/packages/plg_mokosuitecross_blogger/blogger.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Google Blogger - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_bluesky/bluesky.xml b/source/packages/plg_mokosuitecross_bluesky/bluesky.xml index babe6f88..2a61c787 100644 --- a/source/packages/plg_mokosuitecross_bluesky/bluesky.xml +++ b/source/packages/plg_mokosuitecross_bluesky/bluesky.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Bluesky - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_brevo/brevo.xml b/source/packages/plg_mokosuitecross_brevo/brevo.xml index d5c7f2bb..66d07d2b 100644 --- a/source/packages/plg_mokosuitecross_brevo/brevo.xml +++ b/source/packages/plg_mokosuitecross_brevo/brevo.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Brevo (Sendinblue) - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_constantcontact/constantcontact.xml b/source/packages/plg_mokosuitecross_constantcontact/constantcontact.xml index b9aabb5c..7e5d355a 100644 --- a/source/packages/plg_mokosuitecross_constantcontact/constantcontact.xml +++ b/source/packages/plg_mokosuitecross_constantcontact/constantcontact.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Constant Contact - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_convertkit/convertkit.xml b/source/packages/plg_mokosuitecross_convertkit/convertkit.xml index f4ea8b1d..85ad6ab2 100644 --- a/source/packages/plg_mokosuitecross_convertkit/convertkit.xml +++ b/source/packages/plg_mokosuitecross_convertkit/convertkit.xml @@ -1,7 +1,7 @@ MokoSuiteCross - ConvertKit - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_devto/devto.xml b/source/packages/plg_mokosuitecross_devto/devto.xml index 86d5170f..1dffc879 100644 --- a/source/packages/plg_mokosuitecross_devto/devto.xml +++ b/source/packages/plg_mokosuitecross_devto/devto.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Dev.to - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_discord/discord.xml b/source/packages/plg_mokosuitecross_discord/discord.xml index 2d76d236..81764403 100644 --- a/source/packages/plg_mokosuitecross_discord/discord.xml +++ b/source/packages/plg_mokosuitecross_discord/discord.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Discord - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_facebook/facebook.xml b/source/packages/plg_mokosuitecross_facebook/facebook.xml index b3ab2cb1..f322bc75 100644 --- a/source/packages/plg_mokosuitecross_facebook/facebook.xml +++ b/source/packages/plg_mokosuitecross_facebook/facebook.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Facebook / Meta - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php b/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php index 7812f160..cdc1901e 100644 --- a/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php +++ b/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php @@ -18,22 +18,10 @@ use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteIn use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Event\SubscriberInterface; -/** - * Facebook/Meta service plugin for MokoSuiteCross. - * - * Supports two modes: - * 1. Default MokoSuite App — pre-configured app credentials (hidden from admin UI) - * 2. Custom App — user provides their own Facebook App ID and Page Access Token - * - * Credentials format: - * { - * "mode": "default" | "custom", - * "page_access_token": "...", // Only for custom mode - * "page_id": "..." // Required — Facebook Page ID - * } - */ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface { + private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'wmv', 'webm']; + public static function getSubscribedEvents(): array { return [ @@ -65,18 +53,84 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing token or page_id']]; } + $contentType = $params['content_type'] ?? 'feed'; + + return match ($contentType) { + 'reel' => $this->publishReel($message, $media, $token, $pageId, $params), + 'story' => $this->publishStory($media, $token, $pageId), + default => $this->publishFeed($message, $token, $pageId, $params), + }; + } + + private function publishFeed(string $message, string $token, string $pageId, array $params): array + { $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/feed'; $postData = [ 'message' => $message, ]; - // Attach link if provided in params if (!empty($params['link'])) { $postData['link'] = $params['link']; } - $ch = curl_init($apiUrl); + if (!empty($params['scheduled_at'])) { + $timestamp = is_numeric($params['scheduled_at']) + ? (int) $params['scheduled_at'] + : strtotime($params['scheduled_at']); + + if ($timestamp !== false && $timestamp > 0) { + $postData['scheduled_publish_time'] = $timestamp; + $postData['published'] = 'false'; + } + } elseif (!empty($params['draft'])) { + $postData['published'] = 'false'; + } + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function publishReel(string $message, array $media, string $token, string $pageId, array $params): array + { + if (empty($media[0])) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Reel requires a video URL']]; + } + + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/video_reels'; + + $postData = [ + 'upload_phase' => 'finish', + 'video_url' => $media[0], + 'description' => $message, + ]; + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function publishStory(array $media, string $token, string $pageId): array + { + if (empty($media[0])) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Story requires a media URL']]; + } + + $mediaUrl = $media[0]; + $extension = strtolower(pathinfo(parse_url($mediaUrl, PHP_URL_PATH) ?: $mediaUrl, PATHINFO_EXTENSION)); + $isVideo = in_array($extension, self::VIDEO_EXTENSIONS, true); + + if ($isVideo) { + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/video_stories'; + $postData = ['video_url' => $mediaUrl]; + } else { + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/photo_stories'; + $postData = ['photo_url' => $mediaUrl]; + } + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function apiPost(string $url, array $postData, string $token): array + { + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($postData), @@ -88,20 +142,18 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($response, true) ?: []; - if ($httpCode === 200 && !empty($data['id'])) { + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) { return ['success' => true, 'platform_post_id' => $data['id'], 'response' => $data]; } @@ -143,7 +195,7 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit public function getMaxLength(): int { - return 0; // No practical limit + return 0; } public function supportsMedia(): bool diff --git a/source/packages/plg_mokosuitecross_ghost/ghost.xml b/source/packages/plg_mokosuitecross_ghost/ghost.xml index 8dda8e7b..4d9daad5 100644 --- a/source/packages/plg_mokosuitecross_ghost/ghost.xml +++ b/source/packages/plg_mokosuitecross_ghost/ghost.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Ghost - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_googlebusiness/googlebusiness.xml b/source/packages/plg_mokosuitecross_googlebusiness/googlebusiness.xml index 2ccf3e42..4f8f8437 100644 --- a/source/packages/plg_mokosuitecross_googlebusiness/googlebusiness.xml +++ b/source/packages/plg_mokosuitecross_googlebusiness/googlebusiness.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Google Business Profile - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_googlechat/googlechat.xml b/source/packages/plg_mokosuitecross_googlechat/googlechat.xml index 3a3181c1..784224f7 100644 --- a/source/packages/plg_mokosuitecross_googlechat/googlechat.xml +++ b/source/packages/plg_mokosuitecross_googlechat/googlechat.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Google Chat - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_hashnode/hashnode.xml b/source/packages/plg_mokosuitecross_hashnode/hashnode.xml index ae9566cd..af02098c 100644 --- a/source/packages/plg_mokosuitecross_hashnode/hashnode.xml +++ b/source/packages/plg_mokosuitecross_hashnode/hashnode.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Hashnode - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_instagram/instagram.xml b/source/packages/plg_mokosuitecross_instagram/instagram.xml index 13952077..26620afa 100644 --- a/source/packages/plg_mokosuitecross_instagram/instagram.xml +++ b/source/packages/plg_mokosuitecross_instagram/instagram.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Instagram - 01.08.00 + 01.08.61 2026-06-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php index 693d5da5..317535eb 100644 --- a/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php +++ b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php @@ -20,7 +20,7 @@ use Joomla\Event\SubscriberInterface; /** * Instagram service plugin for MokoSuiteCross. * - * Uses the Meta Content Publishing API — a 2-step flow: + * Uses the Meta Content Publishing API -- a 2-step flow: * 1. Create a media container via POST /{ig_user_id}/media * 2. Publish the container via POST /{ig_user_id}/media_publish */ @@ -50,24 +50,128 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or Instagram account ID.']]; } - // Step 1: Create media container - $containerUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media'; - $containerData = [ - 'caption' => mb_substr($message, 0, 2200), - 'access_token' => $token, - ]; - - // Attach image if provided - if (!empty($media[0])) { - $containerData['image_url'] = $media[0]; - } else { + if (empty($media)) { return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Instagram requires at least one image or video.']]; } - $ch = curl_init($containerUrl); + $caption = mb_substr($message, 0, 2200); + $mediaType = $params['media_type'] ?? ''; + $altText = $params['alt_text'] ?? ''; + + if ($mediaType === 'reels') { + return $this->publishReels($accountId, $token, $caption, $media[0]); + } + + if ($mediaType === 'stories') { + return $this->publishStories($accountId, $token, $media[0]); + } + + if (\count($media) > 1) { + return $this->publishCarousel($accountId, $token, $caption, $media, $altText); + } + + $fields = [ + 'caption' => $caption, + 'image_url' => $media[0], + ]; + + if ($altText !== '') { + $fields['alt_text'] = $altText; + } + + $container = $this->createContainer($accountId, $token, $fields); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function publishCarousel(string $accountId, string $token, string $caption, array $media, string $altText): array + { + $media = \array_slice($media, 0, 10); + $childIds = []; + + foreach ($media as $url) { + $fields = ['is_carousel_item' => 'true']; + + if ($this->isVideoUrl($url)) { + $fields['video_url'] = $url; + } else { + $fields['image_url'] = $url; + + if ($altText !== '') { + $fields['alt_text'] = $altText; + } + } + + $child = $this->createContainer($accountId, $token, $fields); + + if (!$child['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $child['data'] ?? ['error' => $child['error']]]; + } + + $childIds[] = $child['id']; + } + + $carousel = $this->createContainer($accountId, $token, [ + 'media_type' => 'CAROUSEL', + 'caption' => $caption, + 'children' => implode(',', $childIds), + ]); + + if (!$carousel['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $carousel['data'] ?? ['error' => $carousel['error']]]; + } + + return $this->publishContainer($accountId, $token, $carousel['id']); + } + + private function publishReels(string $accountId, string $token, string $caption, string $videoUrl): array + { + $container = $this->createContainer($accountId, $token, [ + 'media_type' => 'REELS', + 'video_url' => $videoUrl, + 'caption' => $caption, + ]); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function publishStories(string $accountId, string $token, string $mediaUrl): array + { + $fields = ['media_type' => 'STORIES']; + + if ($this->isVideoUrl($mediaUrl)) { + $fields['video_url'] = $mediaUrl; + } else { + $fields['image_url'] = $mediaUrl; + } + + $container = $this->createContainer($accountId, $token, $fields); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function createContainer(string $accountId, string $token, array $fields): array + { + $url = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media'; + + $fields['access_token'] = $token; + + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($containerData), + CURLOPT_POSTFIELDS => http_build_query($fields), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -75,36 +179,34 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - + return ['success' => false, 'error' => 'Connection error: ' . $curlError]; } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($response, true) ?: []; if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) { - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + return ['success' => false, 'error' => $data['error']['message'] ?? 'Container creation failed', 'data' => $data]; } - $containerId = $data['id']; + return ['success' => true, 'id' => $data['id'], 'data' => $data]; + } - // Step 2: Publish the container - $publishUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish'; - $publishData = [ - 'creation_id' => $containerId, - 'access_token' => $token, - ]; + private function publishContainer(string $accountId, string $token, string $containerId): array + { + $url = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish'; - $ch = curl_init($publishUrl); + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($publishData), + CURLOPT_POSTFIELDS => http_build_query([ + 'creation_id' => $containerId, + 'access_token' => $token, + ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -112,14 +214,11 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); @@ -132,6 +231,11 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui return ['success' => false, 'platform_post_id' => '', 'response' => $data]; } + private function isVideoUrl(string $url): bool + { + return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm)(\?|$)/i', $url); + } + public function validateCredentials(array $credentials): array { $token = $this->resolveCredential($credentials, 'access_token'); @@ -150,13 +254,9 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => '']; - } curl_close($ch); @@ -183,6 +283,6 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui public function getSupportedMediaTypes(): array { - return ['image', 'video']; + return ['image', 'video', 'carousel', 'reels', 'stories']; } } diff --git a/source/packages/plg_mokosuitecross_linkedin/linkedin.xml b/source/packages/plg_mokosuitecross_linkedin/linkedin.xml index cf2b21be..c0541f4c 100644 --- a/source/packages/plg_mokosuitecross_linkedin/linkedin.xml +++ b/source/packages/plg_mokosuitecross_linkedin/linkedin.xml @@ -1,7 +1,7 @@ MokoSuiteCross - LinkedIn - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_mailchimp/mailchimp.xml b/source/packages/plg_mokosuitecross_mailchimp/mailchimp.xml index 5b5332bb..a95ab4bb 100644 --- a/source/packages/plg_mokosuitecross_mailchimp/mailchimp.xml +++ b/source/packages/plg_mokosuitecross_mailchimp/mailchimp.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Mailchimp - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_mastodon/mastodon.xml b/source/packages/plg_mokosuitecross_mastodon/mastodon.xml index 89174493..d9830a28 100644 --- a/source/packages/plg_mokosuitecross_mastodon/mastodon.xml +++ b/source/packages/plg_mokosuitecross_mastodon/mastodon.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Mastodon - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_matrix/matrix.xml b/source/packages/plg_mokosuitecross_matrix/matrix.xml index 1befcb67..95ae0ed3 100644 --- a/source/packages/plg_mokosuitecross_matrix/matrix.xml +++ b/source/packages/plg_mokosuitecross_matrix/matrix.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Matrix / Element - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_medium/medium.xml b/source/packages/plg_mokosuitecross_medium/medium.xml index 0b6e7965..d1cdd7d1 100644 --- a/source/packages/plg_mokosuitecross_medium/medium.xml +++ b/source/packages/plg_mokosuitecross_medium/medium.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Medium - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_mokosuitecalendar/mokosuitecalendar.xml b/source/packages/plg_mokosuitecross_mokosuitecalendar/mokosuitecalendar.xml index 594f5bce..d0b3a075 100644 --- a/source/packages/plg_mokosuitecross_mokosuitecalendar/mokosuitecalendar.xml +++ b/source/packages/plg_mokosuitecross_mokosuitecalendar/mokosuitecalendar.xml @@ -1,7 +1,7 @@ MokoSuiteCross - MokoSuiteCalendar Events - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_mokosuitegallery/mokosuitegallery.xml b/source/packages/plg_mokosuitecross_mokosuitegallery/mokosuitegallery.xml index 63f9474b..01ec4b1d 100644 --- a/source/packages/plg_mokosuitecross_mokosuitegallery/mokosuitegallery.xml +++ b/source/packages/plg_mokosuitecross_mokosuitegallery/mokosuitegallery.xml @@ -1,7 +1,7 @@ MokoSuiteCross - MokoSuiteGallery - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini b/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini index 3c87b214..e5de006f 100644 --- a/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini +++ b/source/packages/plg_mokosuitecross_nostr/language/en-GB/plg_mokosuitecross_nostr.ini @@ -1,2 +1,2 @@ PLG_MOKOSUITECROSS_NOSTR="MokoSuiteCross - Nostr" -PLG_MOKOSUITECROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr." +PLG_MOKOSUITECROSS_NOSTR_DESCRIPTION="Cross-post Joomla articles to Nostr relays via NIP-01 WebSocket protocol. Requires PHP ext-gmp for secp256k1 Schnorr signing." diff --git a/source/packages/plg_mokosuitecross_nostr/nostr.xml b/source/packages/plg_mokosuitecross_nostr/nostr.xml index d7ca923e..a42a9513 100644 --- a/source/packages/plg_mokosuitecross_nostr/nostr.xml +++ b/source/packages/plg_mokosuitecross_nostr/nostr.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Nostr - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php b/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php index 56a5eed7..2f27c9e7 100644 --- a/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php +++ b/source/packages/plg_mokosuitecross_nostr/src/Extension/NostrService.php @@ -20,12 +20,18 @@ use Joomla\Event\SubscriberInterface; /** * Nostr service plugin for MokoSuiteCross. * - * Nostr uses NIP-01 WebSocket relays for event publishing. - * This is a stub — full WebSocket implementation is deferred. - * Events are signed with the private key and sent to configured relays. + * Publishes kind-1 text note events to NIP-01 WebSocket relays. + * Uses BIP-340 Schnorr signatures over secp256k1 (requires ext-gmp). + * + * Credentials: private_key (64-char hex nsec), relays (comma-separated wss:// URLs) */ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface { + private const SECP256K1_P = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F'; + private const SECP256K1_N = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'; + private const SECP256K1_GX = '79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'; + private const SECP256K1_GY = '483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8'; + public static function getSubscribedEvents(): array { return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; @@ -43,6 +49,10 @@ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr public function publish(string $message, array $media, array $credentials, array $params): array { + if (!extension_loaded('gmp')) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'PHP ext-gmp is required for Nostr signing.']]; + } + $privateKey = $credentials['private_key'] ?? ''; $relays = $credentials['relays'] ?? ''; @@ -50,48 +60,393 @@ class NostrService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing private key or relay URLs.']]; } - // Nostr requires WebSocket connections to relays (wss://). - // Full NIP-01 event signing and relay publishing is not yet implemented. + $privateKey = strtolower(trim($privateKey)); + + if (!preg_match('/^[0-9a-f]{64}$/', $privateKey)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Private key must be 64 hex characters.']]; + } + + $pubkey = $this->getPublicKey($privateKey); + + $event = $this->createEvent($pubkey, $message); + $event['sig'] = $this->schnorrSign($event['id'], $privateKey); + + $relayList = array_filter(array_map('trim', explode(',', $relays))); + $lastError = ''; + $published = false; + + foreach ($relayList as $relayUrl) { + $result = $this->publishToRelay($relayUrl, $event); + + if ($result['success']) { + $published = true; + break; + } + + $lastError = $result['error']; + } + + if (!$published) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'All relays failed. Last: ' . $lastError]]; + } + return [ - 'success' => false, - 'platform_post_id' => '', - 'response' => ['error' => 'Nostr WebSocket relay publishing is not yet implemented. This service will be available in a future release.'], + 'success' => true, + 'platform_post_id' => $event['id'], + 'response' => ['event_id' => $event['id'], 'relay' => $relayUrl ?? ''], ]; } public function validateCredentials(array $credentials): array { - $privateKey = $credentials['private_key'] ?? ''; + if (!extension_loaded('gmp')) { + return ['valid' => false, 'message' => 'PHP ext-gmp is required for Nostr.', 'account_name' => '']; + } + + $privateKey = strtolower(trim($credentials['private_key'] ?? '')); $relays = $credentials['relays'] ?? ''; if (empty($privateKey)) { return ['valid' => false, 'message' => 'Private key is required.', 'account_name' => '']; } + if (!preg_match('/^[0-9a-f]{64}$/', $privateKey)) { + return ['valid' => false, 'message' => 'Private key must be 64 hex characters.', 'account_name' => '']; + } + if (empty($relays)) { return ['valid' => false, 'message' => 'At least one relay URL is required.', 'account_name' => '']; } - // Validate that relay URLs look like WebSocket URLs $relayList = array_filter(array_map('trim', explode(',', $relays))); - $valid = true; foreach ($relayList as $relay) { if (!str_starts_with($relay, 'wss://') && !str_starts_with($relay, 'ws://')) { - $valid = false; - break; + return ['valid' => false, 'message' => 'Relay URLs must start with wss:// or ws://.', 'account_name' => '']; } } - if (!$valid) { - return ['valid' => false, 'message' => 'Relay URLs must start with wss:// or ws://.', 'account_name' => '']; - } + $pubkey = $this->getPublicKey($privateKey); + $npub = substr($pubkey, 0, 16) . '...'; - return ['valid' => true, 'message' => 'Credentials configured (' . count($relayList) . ' relay(s))', 'account_name' => 'Nostr']; + return ['valid' => true, 'message' => count($relayList) . ' relay(s) configured', 'account_name' => 'npub:' . $npub]; } public function getSupportedMediaTypes(): array { return []; } + + // -- NIP-01 event creation -- + + private function createEvent(string $pubkey, string $content, int $kind = 1, array $tags = []): array + { + $createdAt = time(); + $serialized = json_encode([0, $pubkey, $createdAt, $kind, $tags, $content], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $id = hash('sha256', $serialized); + + return [ + 'id' => $id, + 'pubkey' => $pubkey, + 'created_at' => $createdAt, + 'kind' => $kind, + 'tags' => $tags, + 'content' => $content, + 'sig' => '', + ]; + } + + // -- WebSocket relay publishing -- + + private function publishToRelay(string $relayUrl, array $event): array + { + $parsed = parse_url($relayUrl); + + if (!$parsed || !isset($parsed['host'])) { + return ['success' => false, 'error' => 'Invalid relay URL']; + } + + $scheme = $parsed['scheme'] ?? 'wss'; + $host = $parsed['host']; + $port = $parsed['port'] ?? ($scheme === 'wss' ? 443 : 80); + $path = $parsed['path'] ?? '/'; + $useTls = ($scheme === 'wss'); + + $address = ($useTls ? 'tls://' : 'tcp://') . $host . ':' . $port; + $context = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + + $socket = @stream_socket_client($address, $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context); + + if (!$socket) { + return ['success' => false, 'error' => "Connection failed: {$errstr} ({$errno})"]; + } + + stream_set_timeout($socket, 10); + + // WebSocket upgrade handshake + $wsKey = base64_encode(random_bytes(16)); + $handshake = "GET {$path} HTTP/1.1\r\n" + . "Host: {$host}\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Key: {$wsKey}\r\n" + . "Sec-WebSocket-Version: 13\r\n" + . "\r\n"; + + fwrite($socket, $handshake); + + $response = ''; + + while (($line = fgets($socket)) !== false) { + $response .= $line; + + if (trim($line) === '') { + break; + } + } + + if (strpos($response, '101') === false) { + fclose($socket); + + return ['success' => false, 'error' => 'WebSocket upgrade failed']; + } + + // Send EVENT message + $payload = json_encode(['EVENT', $event], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $this->wsWrite($socket, $payload); + + // Read OK response (with timeout) + $reply = $this->wsRead($socket); + fclose($socket); + + if ($reply === null) { + return ['success' => false, 'error' => 'No response from relay']; + } + + $decoded = json_decode($reply, true); + + if (!is_array($decoded) || ($decoded[0] ?? '') !== 'OK') { + $msg = is_array($decoded) ? ($decoded[3] ?? $decoded[2] ?? 'Unknown error') : 'Invalid response'; + + return ['success' => false, 'error' => (string) $msg]; + } + + // ["OK", event_id, true/false, message] + $accepted = $decoded[2] ?? false; + + if (!$accepted) { + return ['success' => false, 'error' => $decoded[3] ?? 'Relay rejected event']; + } + + return ['success' => true, 'error' => '']; + } + + private function wsWrite($socket, string $data): void + { + $len = strlen($data); + $frame = chr(0x81); // text frame, FIN bit set + $mask = random_bytes(4); + + if ($len < 126) { + $frame .= chr($len | 0x80); // mask bit set + } elseif ($len < 65536) { + $frame .= chr(126 | 0x80) . pack('n', $len); + } else { + $frame .= chr(127 | 0x80) . pack('J', $len); + } + + $frame .= $mask; + + for ($i = 0; $i < $len; $i++) { + $frame .= $data[$i] ^ $mask[$i % 4]; + } + + fwrite($socket, $frame); + } + + private function wsRead($socket): ?string + { + $header = fread($socket, 2); + + if ($header === false || strlen($header) < 2) { + return null; + } + + $len = ord($header[1]) & 0x7F; + + if ($len === 126) { + $ext = fread($socket, 2); + $len = unpack('n', $ext)[1]; + } elseif ($len === 127) { + $ext = fread($socket, 8); + $len = unpack('J', $ext)[1]; + } + + $masked = (ord($header[1]) & 0x80) !== 0; + $mask = $masked ? fread($socket, 4) : ''; + $data = ''; + + while (strlen($data) < $len) { + $chunk = fread($socket, $len - strlen($data)); + + if ($chunk === false) { + break; + } + + $data .= $chunk; + } + + if ($masked) { + for ($i = 0; $i < strlen($data); $i++) { + $data[$i] = $data[$i] ^ $mask[$i % 4]; + } + } + + return $data; + } + + // -- BIP-340 Schnorr signature over secp256k1 -- + + private function getPublicKey(string $privateKeyHex): string + { + $d = gmp_init($privateKeyHex, 16); + $G = [gmp_init(self::SECP256K1_GX, 16), gmp_init(self::SECP256K1_GY, 16)]; + + $point = $this->ecMultiply($G, $d); + + return str_pad(gmp_strval($point[0], 16), 64, '0', STR_PAD_LEFT); + } + + private function schnorrSign(string $messageHex, string $privateKeyHex): string + { + $p = gmp_init(self::SECP256K1_P, 16); + $n = gmp_init(self::SECP256K1_N, 16); + $G = [gmp_init(self::SECP256K1_GX, 16), gmp_init(self::SECP256K1_GY, 16)]; + + $d = gmp_init($privateKeyHex, 16); + $P = $this->ecMultiply($G, $d); + $px = str_pad(gmp_strval($P[0], 16), 64, '0', STR_PAD_LEFT); + + // BIP-340: if P.y is odd, negate d + if (gmp_testbit($P[1], 0)) { + $d = gmp_sub($n, $d); + } + + $dBytes = hex2bin(str_pad(gmp_strval($d, 16), 64, '0', STR_PAD_LEFT)); + $auxRand = random_bytes(32); + $t = $dBytes ^ $this->taggedHash('BIP0340/aux', $auxRand); + $pxBytes = hex2bin($px); + $msgBytes = hex2bin($messageHex); + + $rand = $this->taggedHash('BIP0340/nonce', $t . $pxBytes . $msgBytes); + $k0 = gmp_mod(gmp_init(bin2hex($rand), 16), $n); + + if (gmp_cmp($k0, 0) === 0) { + return str_repeat('00', 64); + } + + $R = $this->ecMultiply($G, $k0); + $k = gmp_testbit($R[1], 0) ? gmp_sub($n, $k0) : $k0; + + $rx = str_pad(gmp_strval($R[0], 16), 64, '0', STR_PAD_LEFT); + $rxBytes = hex2bin($rx); + + $eHash = $this->taggedHash('BIP0340/challenge', $rxBytes . $pxBytes . $msgBytes); + $e = gmp_mod(gmp_init(bin2hex($eHash), 16), $n); + + $s = gmp_mod(gmp_add($k, gmp_mul($e, $d)), $n); + + return $rx . str_pad(gmp_strval($s, 16), 64, '0', STR_PAD_LEFT); + } + + private function taggedHash(string $tag, string $data): string + { + $tagHash = hash('sha256', $tag, true); + + return hash('sha256', $tagHash . $tagHash . $data, true); + } + + // -- secp256k1 elliptic curve arithmetic -- + + private function ecMultiply(array $point, \GMP $scalar): array + { + $result = null; + $addend = $point; + $n = gmp_init(self::SECP256K1_N, 16); + + $scalar = gmp_mod($scalar, $n); + + while (gmp_cmp($scalar, 0) > 0) { + if (gmp_testbit($scalar, 0)) { + $result = $result === null ? $addend : $this->ecAdd($result, $addend); + } + + $addend = $this->ecDouble($addend); + $scalar = gmp_div_q($scalar, 2); + } + + return $result; + } + + private function ecAdd(array $p1, array $p2): array + { + $prime = gmp_init(self::SECP256K1_P, 16); + + if (gmp_cmp($p1[0], $p2[0]) === 0 && gmp_cmp($p1[1], $p2[1]) === 0) { + return $this->ecDouble($p1); + } + + $dx = gmp_mod(gmp_sub($p2[0], $p1[0]), $prime); + + if (gmp_cmp($dx, 0) < 0) { + $dx = gmp_add($dx, $prime); + } + + $dy = gmp_mod(gmp_sub($p2[1], $p1[1]), $prime); + + if (gmp_cmp($dy, 0) < 0) { + $dy = gmp_add($dy, $prime); + } + + $slope = gmp_mod(gmp_mul($dy, gmp_invert($dx, $prime)), $prime); + $x3 = gmp_mod(gmp_sub(gmp_sub(gmp_mul($slope, $slope), $p1[0]), $p2[0]), $prime); + $y3 = gmp_mod(gmp_sub(gmp_mul($slope, gmp_sub($p1[0], $x3)), $p1[1]), $prime); + + if (gmp_cmp($x3, 0) < 0) { + $x3 = gmp_add($x3, $prime); + } + + if (gmp_cmp($y3, 0) < 0) { + $y3 = gmp_add($y3, $prime); + } + + return [$x3, $y3]; + } + + private function ecDouble(array $point): array + { + $prime = gmp_init(self::SECP256K1_P, 16); + + // secp256k1 has a=0, so slope = 3*x^2 / (2*y) + $num = gmp_mod(gmp_mul(3, gmp_mul($point[0], $point[0])), $prime); + $denom = gmp_mod(gmp_mul(2, $point[1]), $prime); + + if (gmp_cmp($denom, 0) < 0) { + $denom = gmp_add($denom, $prime); + } + + $slope = gmp_mod(gmp_mul($num, gmp_invert($denom, $prime)), $prime); + $x3 = gmp_mod(gmp_sub(gmp_mul($slope, $slope), gmp_mul(2, $point[0])), $prime); + $y3 = gmp_mod(gmp_sub(gmp_mul($slope, gmp_sub($point[0], $x3)), $point[1]), $prime); + + if (gmp_cmp($x3, 0) < 0) { + $x3 = gmp_add($x3, $prime); + } + + if (gmp_cmp($y3, 0) < 0) { + $y3 = gmp_add($y3, $prime); + } + + return [$x3, $y3]; + } } diff --git a/source/packages/plg_mokosuitecross_ntfy/ntfy.xml b/source/packages/plg_mokosuitecross_ntfy/ntfy.xml index 006d03f9..32d2f40d 100644 --- a/source/packages/plg_mokosuitecross_ntfy/ntfy.xml +++ b/source/packages/plg_mokosuitecross_ntfy/ntfy.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Ntfy Push Notifications - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_pinterest/pinterest.xml b/source/packages/plg_mokosuitecross_pinterest/pinterest.xml index 1e8b2bf3..f7ea618b 100644 --- a/source/packages/plg_mokosuitecross_pinterest/pinterest.xml +++ b/source/packages/plg_mokosuitecross_pinterest/pinterest.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Pinterest - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_reddit/reddit.xml b/source/packages/plg_mokosuitecross_reddit/reddit.xml index bb40da2d..af11186a 100644 --- a/source/packages/plg_mokosuitecross_reddit/reddit.xml +++ b/source/packages/plg_mokosuitecross_reddit/reddit.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Reddit - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_rssfeed/rssfeed.xml b/source/packages/plg_mokosuitecross_rssfeed/rssfeed.xml index 4db67fb4..56061a2b 100644 --- a/source/packages/plg_mokosuitecross_rssfeed/rssfeed.xml +++ b/source/packages/plg_mokosuitecross_rssfeed/rssfeed.xml @@ -1,7 +1,7 @@ MokoSuiteCross - RSS Feed - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_sendgrid/sendgrid.xml b/source/packages/plg_mokosuitecross_sendgrid/sendgrid.xml index 405c31d2..2f27a4f0 100644 --- a/source/packages/plg_mokosuitecross_sendgrid/sendgrid.xml +++ b/source/packages/plg_mokosuitecross_sendgrid/sendgrid.xml @@ -1,7 +1,7 @@ MokoSuiteCross - SendGrid - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_slack/slack.xml b/source/packages/plg_mokosuitecross_slack/slack.xml index e194d2b3..17de8217 100644 --- a/source/packages/plg_mokosuitecross_slack/slack.xml +++ b/source/packages/plg_mokosuitecross_slack/slack.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Slack - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_teams/teams.xml b/source/packages/plg_mokosuitecross_teams/teams.xml index 352a8c67..bbda16e4 100644 --- a/source/packages/plg_mokosuitecross_teams/teams.xml +++ b/source/packages/plg_mokosuitecross_teams/teams.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Microsoft Teams - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_telegram/telegram.xml b/source/packages/plg_mokosuitecross_telegram/telegram.xml index 744251f8..1f9306e5 100644 --- a/source/packages/plg_mokosuitecross_telegram/telegram.xml +++ b/source/packages/plg_mokosuitecross_telegram/telegram.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Telegram - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php b/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php index 04b77d5a..bc4f1a81 100644 --- a/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php +++ b/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php @@ -17,15 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Event\SubscriberInterface; -/** - * Threads (Meta) service plugin for MokoSuiteCross. - * - * Uses the Threads Publishing API — a 2-step flow: - * 1. Create a media container via POST /{user_id}/threads - * 2. Publish the container via POST /{user_id}/threads_publish - */ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface { + private const API_BASE = 'https://graph.threads.net/v1.0/'; + private const MAX_CAROUSEL_ITEMS = 20; + private const MAX_POLL_OPTIONS = 4; + private const MIN_POLL_OPTIONS = 2; + public static function getSubscribedEvents(): array { return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; @@ -50,62 +48,104 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or user ID.']]; } - // Step 1: Create media container - $containerUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads'; + $text = mb_substr($message, 0, 500); + $media = array_filter($media); + + if (\count($media) > 1) { + return $this->publishCarousel($text, $media, $userId, $token, $params); + } + + return $this->publishSinglePost($text, $media, $userId, $token, $params); + } + + private function publishSinglePost(string $text, array $media, string $userId, string $token, array $params): array + { + $containerUrl = self::API_BASE . urlencode($userId) . '/threads'; $containerData = [ - 'text' => mb_substr($message, 0, 500), + 'text' => $text, 'access_token' => $token, ]; - // Attach image if provided if (!empty($media[0])) { - $containerData['media_type'] = 'IMAGE'; - $containerData['image_url'] = $media[0]; + $mediaType = $this->detectMediaType($media[0]); + $containerData['media_type'] = $mediaType; + $containerData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $media[0]; } else { $containerData['media_type'] = 'TEXT'; } - $ch = curl_init($containerUrl); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($containerData), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); + $this->applyPollOptions($containerData, $params); + $this->applySpoilerFlag($containerData, $params); - $response = curl_exec($ch); + $result = $this->apiPost($containerUrl, $containerData); - if ($response === false) { - - $curlError = curl_error($ch); - - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - - } - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $data = json_decode($response, true) ?: []; - - if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) { - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + if (!$result['success']) { + return $result; } - $containerId = $data['id']; + return $this->publishContainer($userId, $token, $result['response']['id']); + } - // Step 2: Publish the container - $publishUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads_publish'; + private function publishCarousel(string $text, array $media, string $userId, string $token, array $params): array + { + $items = \array_slice($media, 0, self::MAX_CAROUSEL_ITEMS); + $childIds = []; + + foreach ($items as $url) { + $mediaType = $this->detectMediaType($url); + $childData = [ + 'is_carousel_item' => 'true', + 'media_type' => $mediaType, + 'access_token' => $token, + ]; + $childData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $url; + + $childUrl = self::API_BASE . urlencode($userId) . '/threads'; + $result = $this->apiPost($childUrl, $childData); + + if (!$result['success']) { + return $result; + } + + $childIds[] = $result['response']['id']; + } + + $carouselData = [ + 'media_type' => 'CAROUSEL', + 'children' => implode(',', $childIds), + 'text' => $text, + 'access_token' => $token, + ]; + + $this->applySpoilerFlag($carouselData, $params); + + $carouselUrl = self::API_BASE . urlencode($userId) . '/threads'; + $result = $this->apiPost($carouselUrl, $carouselData); + + if (!$result['success']) { + return $result; + } + + return $this->publishContainer($userId, $token, $result['response']['id']); + } + + private function publishContainer(string $userId, string $token, string $containerId): array + { + $publishUrl = self::API_BASE . urlencode($userId) . '/threads_publish'; $publishData = [ 'creation_id' => $containerId, 'access_token' => $token, ]; - $ch = curl_init($publishUrl); + return $this->apiPost($publishUrl, $publishData); + } + + private function apiPost(string $url, array $data): array + { + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($publishData), + CURLOPT_POSTFIELDS => http_build_query($data), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -113,24 +153,60 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - $data = json_decode($response, true) ?: []; + $responseData = json_decode($response, true) ?: []; - if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) { - return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data]; + if ($httpCode >= 200 && $httpCode < 300 && !empty($responseData['id'])) { + return ['success' => true, 'platform_post_id' => (string) $responseData['id'], 'response' => $responseData]; } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + return ['success' => false, 'platform_post_id' => '', 'response' => $responseData]; + } + + private function applyPollOptions(array &$data, array $params): void + { + $options = $params['poll_options'] ?? []; + + if (empty($options) || !\is_array($options)) { + return; + } + + $options = \array_slice($options, 0, self::MAX_POLL_OPTIONS); + + if (\count($options) < self::MIN_POLL_OPTIONS) { + return; + } + + $data['poll'] = json_encode(['options' => array_values($options)]); + } + + private function applySpoilerFlag(array &$data, array $params): void + { + if (!empty($params['spoiler'])) { + $data['spoiler'] = 'true'; + } + } + + private function detectMediaType(string $url): string + { + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ''); + $videoExtensions = ['.mp4', '.mov', '.avi', '.wmv', '.webm']; + + foreach ($videoExtensions as $ext) { + if (str_ends_with($path, $ext)) { + return 'VIDEO'; + } + } + + return 'IMAGE'; } public function validateCredentials(array $credentials): array @@ -142,7 +218,7 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite return ['valid' => false, 'message' => 'Access token and user ID are required.', 'account_name' => '']; } - $ch = curl_init('https://graph.threads.net/v1.0/' . urlencode($userId) . '?fields=username&access_token=' . urlencode($token)); + $ch = curl_init(self::API_BASE . urlencode($userId) . '?fields=username&access_token=' . urlencode($token)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, diff --git a/source/packages/plg_mokosuitecross_threads/threads.xml b/source/packages/plg_mokosuitecross_threads/threads.xml index d7840189..3a914845 100644 --- a/source/packages/plg_mokosuitecross_threads/threads.xml +++ b/source/packages/plg_mokosuitecross_threads/threads.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Threads (Meta) - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini b/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini index 168c4a3c..39af88ba 100644 --- a/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini +++ b/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini @@ -1,2 +1,3 @@ PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - TikTok" -PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok." +PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok via Content Posting API. Supports video uploads (PULL_FROM_URL) and photo carousels (up to 35 images)." +PLG_MOKOSUITECROSS_TIKTOK_AUDIT_WARNING="Unverified TikTok developer apps can only create private posts. To publish publicly, your app must pass TikTok's Content Posting API audit. Visit the TikTok Developer Portal to submit your app for review." diff --git a/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php b/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php index 561509ee..20ffbf57 100644 --- a/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php +++ b/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php @@ -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,28 +47,129 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']]; } - if (empty($media[0])) { + if (empty($media)) { return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']]; } - $postData = json_encode([ + $postingMode = $params['posting_mode'] ?? 'DIRECT_POST'; + $privacyLevel = $params['privacy_level'] ?? 'SELF_ONLY'; + $caption = mb_substr($message, 0, 2200); + $title = mb_substr(strip_tags($message), 0, 150); + + if ($this->isVideoUrl($media[0])) { + return $this->publishVideo($token, $title, $caption, $media[0], $postingMode, $privacyLevel); + } + + return $this->publishPhotos($token, $title, $caption, $media, $postingMode, $privacyLevel); + } + + private function publishVideo(string $token, string $title, string $caption, string $videoUrl, string $postingMode, string $privacyLevel): array + { + $payload = [ 'post_info' => [ - 'title' => mb_substr(strip_tags($message), 0, 150), - 'description' => mb_substr($message, 0, 2200), - 'privacy_level' => 'SELF_ONLY', + 'title' => $title, + 'description' => $caption, + 'privacy_level' => $privacyLevel, 'disable_comment' => false, ], 'source_info' => [ - 'source' => 'PULL_FROM_URL', - 'video_url' => $media[0], + 'source' => 'PULL_FROM_URL', + 'video_url' => $videoUrl, ], - ]); + 'post_mode' => $postingMode, + ]; - $ch = curl_init(); + $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); curl_setopt_array($ch, [ - CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/', CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, + CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, @@ -77,24 +178,26 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - + return ['success' => false, 'data' => ['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, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + return ['success' => true, 'data' => $data]; } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + return ['success' => false, 'data' => $data]; + } + + private function isVideoUrl(string $url): bool + { + return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm|mkv)(\?|$)/i', $url); } public function validateCredentials(array $credentials): array @@ -129,6 +232,6 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC public function getSupportedMediaTypes(): array { - return ['image', 'video']; + return ['image', 'video', 'carousel']; } } diff --git a/source/packages/plg_mokosuitecross_tiktok/tiktok.xml b/source/packages/plg_mokosuitecross_tiktok/tiktok.xml index a98bbf62..30a24035 100644 --- a/source/packages/plg_mokosuitecross_tiktok/tiktok.xml +++ b/source/packages/plg_mokosuitecross_tiktok/tiktok.xml @@ -1,7 +1,7 @@ MokoSuiteCross - TikTok - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_tumblr/tumblr.xml b/source/packages/plg_mokosuitecross_tumblr/tumblr.xml index 2200a628..ce43ae1a 100644 --- a/source/packages/plg_mokosuitecross_tumblr/tumblr.xml +++ b/source/packages/plg_mokosuitecross_tumblr/tumblr.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Tumblr - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini b/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini index b5163e7c..f81c16a4 100644 --- a/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini +++ b/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini @@ -1,2 +1,3 @@ PLG_MOKOSUITECROSS_TWITTER="MokoSuiteCross - X / Twitter" PLG_MOKOSUITECROSS_TWITTER_DESCRIPTION="Cross-post Joomla articles to X / Twitter." +PLG_MOKOSUITECROSS_TWITTER_COST_WARNING="X API Pricing: Text-only posts cost $0.015 each. Posts containing URLs cost $0.20 each. Cross-posting articles with links will incur URL post charges." diff --git a/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php b/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php index 8d9d2abf..e951fc68 100644 --- a/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php +++ b/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php @@ -51,9 +51,6 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite public function publish(string $message, array $media, array $credentials, array $params): array { - $apiUrl = 'https://api.twitter.com/2/tweets'; - $postData = json_encode(['text' => mb_substr($message, 0, 280)]); - $consumerKey = $credentials['api_key'] ?? ''; $consumerSecret = $credentials['api_secret'] ?? ''; $accessToken = $credentials['access_token'] ?? ''; @@ -67,41 +64,17 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite ]; } - $authHeader = $this->buildOAuth1Header('POST', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); - - $ch = curl_init($apiUrl); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Authorization: ' . $authHeader, - ], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - - $response = curl_exec($ch); - - if ($response === false) { - - $curlError = curl_error($ch); - - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - - } - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $data = json_decode($response, true) ?: []; - - if ($httpCode === 201 && !empty($data['data']['id'])) { - return ['success' => true, 'platform_post_id' => $data['data']['id'], 'response' => $data]; + if (!empty($params['cost_optimize'])) { + return $this->publishCostOptimized($message, $credentials, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + $chunks = $this->splitIntoThread($message); + + if (\count($chunks) === 1) { + return $this->postTweet($chunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + } + + return $this->postThread($chunks, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); } public function validateCredentials(array $credentials): array @@ -158,6 +131,201 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite return true; } + private function splitIntoThread(string $message, int $maxLen = 280): array + { + if (mb_strlen($message) <= $maxLen) { + return [$message]; + } + + $chunks = []; + + while (mb_strlen($message) > $maxLen) { + $segment = mb_substr($message, 0, $maxLen); + + $splitPos = false; + + foreach (['. ', '! ', '? '] as $delimiter) { + $pos = mb_strrpos($segment, $delimiter); + + if ($pos !== false && ($splitPos === false || $pos > $splitPos)) { + $splitPos = $pos + mb_strlen($delimiter) - 1; + } + } + + if ($splitPos === false || $splitPos < 1) { + $splitPos = mb_strrpos($segment, ' '); + } + + if ($splitPos === false || $splitPos < 1) { + $splitPos = $maxLen; + } + + $chunks[] = trim(mb_substr($message, 0, $splitPos)); + $message = trim(mb_substr($message, $splitPos)); + } + + if ($message !== '') { + $chunks[] = $message; + } + + return $chunks; + } + + private function postTweet( + string $text, + ?string $replyToId, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $apiUrl = 'https://api.twitter.com/2/tweets'; + + $body = ['text' => $text]; + + if ($replyToId !== null) { + $body['reply'] = ['in_reply_to_tweet_id' => $replyToId]; + } + + $postData = json_encode($body); + $authHeader = $this->buildOAuth1Header('POST', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: ' . $authHeader, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + + if ($response === false) { + $curlError = curl_error($ch); + curl_close($ch); + + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 201 && !empty($data['data']['id'])) { + return ['success' => true, 'platform_post_id' => $data['data']['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + private function postThread( + array $chunks, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $firstResult = $this->postTweet($chunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$firstResult['success']) { + return $firstResult; + } + + $rootId = $firstResult['platform_post_id']; + $previousId = $rootId; + + for ($i = 1, $count = \count($chunks); $i < $count; $i++) { + $result = $this->postTweet($chunks[$i], $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => [ + 'error' => 'Thread failed at tweet ' . ($i + 1) . ' of ' . $count, + 'root_tweet' => $rootId, + 'failed_tweet' => $result['response'], + ], + ]; + } + + $previousId = $result['platform_post_id']; + } + + return ['success' => true, 'platform_post_id' => $rootId, 'response' => $firstResult['response']]; + } + + private function publishCostOptimized( + string $message, + array $credentials, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $urlPattern = '/https?:\/\/\S+/'; + $urls = []; + preg_match_all($urlPattern, $message, $urls); + $urls = $urls[0] ?? []; + + $textOnly = trim(preg_replace($urlPattern, '', $message)); + $textOnly = preg_replace('/\s{2,}/', ' ', $textOnly); + + if ($textOnly === '' && $urls === []) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Empty message after URL extraction.']]; + } + + $textChunks = $textOnly !== '' ? $this->splitIntoThread($textOnly) : []; + + if ($textChunks === [] && $urls !== []) { + $textChunks = [implode(' ', $urls)]; + $urls = []; + } + + $firstResult = $this->postTweet($textChunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$firstResult['success']) { + return $firstResult; + } + + $rootId = $firstResult['platform_post_id']; + $previousId = $rootId; + + for ($i = 1, $count = \count($textChunks); $i < $count; $i++) { + $result = $this->postTweet($textChunks[$i], $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => ['error' => 'Cost-optimized thread failed at tweet ' . ($i + 1), 'root_tweet' => $rootId], + ]; + } + + $previousId = $result['platform_post_id']; + } + + if ($urls !== []) { + $urlText = implode(' ', $urls); + $result = $this->postTweet($urlText, $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => ['error' => 'Cost-optimized URL reply failed.', 'root_tweet' => $rootId], + ]; + } + } + + return ['success' => true, 'platform_post_id' => $rootId, 'response' => $firstResult['response']]; + } + /** * Build an OAuth 1.0a Authorization header with HMAC-SHA1 signature. */ diff --git a/source/packages/plg_mokosuitecross_twitter/twitter.xml b/source/packages/plg_mokosuitecross_twitter/twitter.xml index a506fa04..aec32634 100644 --- a/source/packages/plg_mokosuitecross_twitter/twitter.xml +++ b/source/packages/plg_mokosuitecross_twitter/twitter.xml @@ -1,7 +1,7 @@ MokoSuiteCross - X / Twitter - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_webhook/webhook.xml b/source/packages/plg_mokosuitecross_webhook/webhook.xml index 8bdfa652..8cb3ab91 100644 --- a/source/packages/plg_mokosuitecross_webhook/webhook.xml +++ b/source/packages/plg_mokosuitecross_webhook/webhook.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Generic Webhook - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_whatsapp/whatsapp.xml b/source/packages/plg_mokosuitecross_whatsapp/whatsapp.xml index c951b816..85feef6e 100644 --- a/source/packages/plg_mokosuitecross_whatsapp/whatsapp.xml +++ b/source/packages/plg_mokosuitecross_whatsapp/whatsapp.xml @@ -1,7 +1,7 @@ MokoSuiteCross - WhatsApp Business - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_wordpress/wordpress.xml b/source/packages/plg_mokosuitecross_wordpress/wordpress.xml index e3f23036..2df53d22 100644 --- a/source/packages/plg_mokosuitecross_wordpress/wordpress.xml +++ b/source/packages/plg_mokosuitecross_wordpress/wordpress.xml @@ -1,7 +1,7 @@ MokoSuiteCross - WordPress - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_mokosuitecross_youtube/youtube.xml b/source/packages/plg_mokosuitecross_youtube/youtube.xml index aa1bab6e..a2ed43df 100644 --- a/source/packages/plg_mokosuitecross_youtube/youtube.xml +++ b/source/packages/plg_mokosuitecross_youtube/youtube.xml @@ -1,7 +1,7 @@ MokoSuiteCross - Youtube - 01.08.00 + 01.08.61 2026-06-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitecross/mokosuitecross.xml b/source/packages/plg_system_mokosuitecross/mokosuitecross.xml index 3b90be6b..2f511fab 100644 --- a/source/packages/plg_system_mokosuitecross/mokosuitecross.xml +++ b/source/packages/plg_system_mokosuitecross/mokosuitecross.xml @@ -1,7 +1,7 @@ System - MokoSuiteCross - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml index f3a7c205..d563e830 100644 --- a/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml +++ b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml @@ -1,7 +1,7 @@ System - MokoSuiteCross Events - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitecross_gallery/mokosuitecross_gallery.xml b/source/packages/plg_system_mokosuitecross_gallery/mokosuitecross_gallery.xml index 19765062..b7e253f6 100644 --- a/source/packages/plg_system_mokosuitecross_gallery/mokosuitecross_gallery.xml +++ b/source/packages/plg_system_mokosuitecross_gallery/mokosuitecross_gallery.xml @@ -1,7 +1,7 @@ System - MokoSuiteCross Gallery - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_task_mokosuitecross/mokosuitecross.xml b/source/packages/plg_task_mokosuitecross/mokosuitecross.xml index 86c4ef1d..10de9995 100644 --- a/source/packages/plg_task_mokosuitecross/mokosuitecross.xml +++ b/source/packages/plg_task_mokosuitecross/mokosuitecross.xml @@ -1,7 +1,7 @@ Task - MokoSuiteCross Queue Processor - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_webservices_mokosuitecross/mokosuitecross.xml b/source/packages/plg_webservices_mokosuitecross/mokosuitecross.xml index c8dcf980..99e4a39f 100644 --- a/source/packages/plg_webservices_mokosuitecross/mokosuitecross.xml +++ b/source/packages/plg_webservices_mokosuitecross/mokosuitecross.xml @@ -1,7 +1,7 @@ Web Services - MokoSuiteCross - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_webservices_mokosuitecross/src/Extension/MokoSuiteCrossWebServices.php b/source/packages/plg_webservices_mokosuitecross/src/Extension/MokoSuiteCrossWebServices.php index f01e64ce..391c15db 100644 --- a/source/packages/plg_webservices_mokosuitecross/src/Extension/MokoSuiteCrossWebServices.php +++ b/source/packages/plg_webservices_mokosuitecross/src/Extension/MokoSuiteCrossWebServices.php @@ -35,8 +35,9 @@ class MokoSuiteCrossWebServices extends CMSPlugin implements SubscriberInterface ]; } - public function onBeforeApiRoute(&$router): void + public function onBeforeApiRoute($event): void { + $router = $event instanceof \Joomla\CMS\Event\AbstractEvent ? $event->getRouter() : $event; $defaults = ['component' => 'com_mokosuitecross']; $router->createCRUDRoutes('v1/mokosuitecross/posts', 'posts', $defaults); @@ -44,7 +45,6 @@ class MokoSuiteCrossWebServices extends CMSPlugin implements SubscriberInterface $router->createCRUDRoutes('v1/mokosuitecross/templates', 'templates', $defaults); $router->createCRUDRoutes('v1/mokosuitecross/logs', 'logs', $defaults); - // Action endpoint: dispatch cross-posts for an article (POST only) $router->addRoute( new \Joomla\Router\Route(['POST'], 'v1/mokosuitecross/dispatch', 'dispatch.dispatch', [], $defaults) ); diff --git a/source/pkg_mokosuitecross.xml b/source/pkg_mokosuitecross.xml index 0d2c4b60..5eaa2479 100644 --- a/source/pkg_mokosuitecross.xml +++ b/source/pkg_mokosuitecross.xml @@ -2,7 +2,7 @@ MokoSuiteCross mokosuitecross - 01.08.00 + 01.08.61 2026-05-28 Moko Consulting hello@mokoconsulting.tech diff --git a/tests/Unit/Helper/PreviewHelperTest.php b/tests/Unit/Helper/PreviewHelperTest.php new file mode 100644 index 00000000..7d3d8dfa --- /dev/null +++ b/tests/Unit/Helper/PreviewHelperTest.php @@ -0,0 +1,128 @@ + + * @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 MokoSuiteCross\Tests\Unit\Helper; + +use Joomla\Component\MokoSuiteCross\Administrator\Helper\PreviewHelper; +use PHPUnit\Framework\Attributes\RequiresMethod; +use PHPUnit\Framework\TestCase; + +#[RequiresMethod(PreviewHelper::class, 'render')] +class PreviewHelperTest extends TestCase +{ + public function testRenderTwitterContainsCharCount(): void + { + $html = PreviewHelper::render('twitter', 'Test Title', 'Hello world', 'https://example.com', '', 'Author'); + + $this->assertStringContainsString('11/280', $html); + } + + public function testRenderTwitterEscapesHtml(): void + { + $html = PreviewHelper::render('twitter', '', 'text', 'https://example.com'); + + $this->assertStringNotContainsString('