diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e868be93 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +# EditorConfig helps maintain consistent coding styles across different editors and IDEs +# https://editorconfig.org/ + +root = true + +# Default settings — Tabs preferred, width = 2 spaces +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +tab_width = 2 + +# PowerShell scripts — tabs, 2-space visual width +[*.ps1] +indent_style = tab +tab_width = 2 +end_of_line = crlf + +# Markdown files — keep trailing whitespace for line breaks +[*.md] +trim_trailing_whitespace = false + +# JSON / YAML files — tabs, 2-space visual width +[*.{json,yml,yaml}] +indent_style = tab +tab_width = 2 + +# Makefiles — always tabs, default width +[Makefile] +indent_style = tab +tab_width = 2 + +# Windows batch scripts — keep CRLF endings +[*.{bat,cmd}] +end_of_line = crlf + +# Shell scripts — ensure LF endings +[*.sh] +end_of_line = lf 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/ci-issue-reporter.yml b/.mokogitea/workflows/ci-issue-reporter.yml new file mode 100644 index 00000000..06ab8a70 --- /dev/null +++ b/.mokogitea/workflows/ci-issue-reporter.yml @@ -0,0 +1,72 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/ci-issue-reporter.yml +# VERSION: 01.00.00 +# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails. +# Clones MokoCLI and runs cli/ci_issue_reporter.sh. + +name: "Universal: CI Issue Reporter" + +on: + workflow_call: + inputs: + gate: + description: "CI gate name (e.g. PR Validation, Repository Health)" + required: true + type: string + details: + description: "Human-readable failure description" + required: true + type: string + severity: + description: "error or warning" + required: false + type: string + default: "error" + workflow: + description: "Workflow name for the issue title" + required: false + type: string + default: "" + secrets: + MOKOGITEA_TOKEN: + required: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + report: + name: "Report: ${{ inputs.gate }}" + runs-on: ubuntu-latest + + steps: + - name: Clone MokoCLI + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli + cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh + + - name: Report CI failure + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + INPUT_GATE: ${{ inputs.gate }} + INPUT_DETAILS: ${{ inputs.details }} + INPUT_SEVERITY: ${{ inputs.severity }} + INPUT_WORKFLOW: ${{ inputs.workflow }} + run: | + chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh + /tmp/mokocli/cli/ci_issue_reporter.sh \ + --gate "$INPUT_GATE" \ + --details "$INPUT_DETAILS" \ + --severity "$INPUT_SEVERITY" \ + --workflow "$INPUT_WORKFLOW" diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml deleted file mode 100644 index bb133edd..00000000 --- a/.mokogitea/workflows/deploy-manual.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Deploy -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API -# PATH: /templates/workflows/joomla/deploy-manual.yml.template -# VERSION: 04.07.00 -# BRIEF: Manual SFTP deploy to dev server for Joomla repos - -name: "Universal: Deploy to Dev (Manual)" - -on: - workflow_dispatch: - inputs: - clear_remote: - description: 'Delete all remote files before uploading' - required: false - default: 'false' - type: boolean - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -permissions: - contents: read - -jobs: - deploy: - name: SFTP Deploy to Dev - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - run: | - php -v && composer --version - - - name: Setup MokoStandards tools - env: - GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' - run: | - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ - /tmp/mokostandards-api 2>/dev/null || true - if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then - cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true - fi - - - name: Check FTP configuration - id: check - env: - HOST: ${{ vars.DEV_FTP_HOST }} - PATH_VAR: ${{ vars.DEV_FTP_PATH }} - PORT: ${{ vars.DEV_FTP_PORT }} - run: | - if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then - echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "host=$HOST" >> "$GITHUB_OUTPUT" - - REMOTE="${PATH_VAR%/}" - echo "remote=$REMOTE" >> "$GITHUB_OUTPUT" - - [ -z "$PORT" ] && PORT="22" - echo "port=$PORT" >> "$GITHUB_OUTPUT" - - - name: Deploy via SFTP - if: steps.check.outputs.skip != 'true' - env: - SFTP_KEY: ${{ secrets.DEV_FTP_KEY }} - SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }} - SFTP_USER: ${{ vars.DEV_FTP_USERNAME }} - run: | - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; } - - printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ - "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \ - > /tmp/sftp-config.json - - if [ -n "$SFTP_KEY" ]; then - echo "$SFTP_KEY" > /tmp/deploy_key - chmod 600 /tmp/deploy_key - printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json - else - printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json - fi - - DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json) - [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote) - - PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) - if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then - php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}" - else - php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" - fi - - rm -f /tmp/deploy_key /tmp/sftp-config.json - - - name: Summary - if: always() - run: | - if [ "${{ steps.check.outputs.skip }}" = "true" ]; then - echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY - else - echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 75a69633..02ea1c19 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 e4e85e84..46364027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ # Changelog ## [Unreleased] +### Added +- **Visual post calendar**: Monthly calendar grid view showing scheduled, queued, and posted cross-posts with status badges (#160) +- **Calendar navigation**: Month-by-month navigation with today highlighting (#160) +- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165) +- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range +- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day +- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload +- **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157) +- **Social image config**: Background color, text color, overlay style, and site name override in component options (#157) +- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161) +- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings +- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields +- **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries +- **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20) +- **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts +- **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) + +### 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 ## [01.07.00] --- 2026-06-23 @@ -12,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 @@ -53,3 +98,19 @@ ## [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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..64dbc890 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ + + +# Contributor Covenant Code of Conduct + +## Our Pledge +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. + +## Our Standards +- Be empathetic and kind +- Be respectful of differing opinions +- Accept constructive feedback +- Own mistakes and learn from them + +Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct. + +## Enforcement +Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly. + +## Enforcement Guidelines +1. **Correction** — Private warning +2. **Warning** — Formal warning and limited interaction +3. **Temporary Ban** — Time-boxed exclusion +4. **Permanent Ban** — Removal from the community + +## Attribution +Adapted from the Contributor Covenant v2.1. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..bad8ed05 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1 @@ +PCEtLQogQ29weXJpZ2h0IChDKSAyMDI2IE1va28gQ29uc3VsdGluZyA8aGVsbG9AbW9rb2NvbnN1bHRpbmcudGVjaD4KCiBUaGlzIGZpbGUgaXMgcGFydCBvZiBhIE1va28gQ29uc3VsdGluZyBwcm9qZWN0LgoKIFNQRFgtTGljZW5zZS1JZGVudGlmaWVyOiBHUEwtMy4wLW9yLWxhdGVyCgogVGhpcyBwcm9ncmFtIGlzIGZyZWUgc29mdHdhcmU7IHlvdSBjYW4gcmVkaXN0cmlidXRlIGl0IGFuZC9vciBtb2RpZnkgaXQgdW5kZXIgdGhlIHRlcm1zIG9mCiB0aGUgR05VIEdlbmVyYWwgUHVibGljIExpY2Vuc2UgYXMgcHVibGlzaGVkIGJ5IHRoZSBGcmVlIFNvZnR3YXJlIEZvdW5kYXRpb247IGVpdGhlciB2ZXJzaW9uIDMKIG9mIHRoZSBMaWNlbnNlLCBvciAoYXQgeW91ciBvcHRpb24pIGFueSBsYXRlciB2ZXJzaW9uLgoKIFRoaXMgcHJvZ3JhbSBpcyBkaXN0cmlidXRlZCBpbiB0aGUgaG9wZSB0aGF0IGl0IHdpbGwgYmUgdXNlZnVsLCBidXQgV0lUSE9VVCBBTlkgV0FSUkFOVFk7CiB3aXRob3V0IGV2ZW4gdGhlIGltcGxpZWQgd2FycmFudHkgb2YgTUVSQ0hBTlRBQklMSVRZIG9yIEZJVE5FU1MgRk9SIEEgUEFSVElDVUxBUiBQVVJQT1NFLgogU2VlIHRoZSBHTlUgR2VuZXJhbCBQdWJsaWMgTGljZW5zZSBmb3IgbW9yZSBkZXRhaWxzLgoKIFlvdSBzaG91bGQgaGF2ZSByZWNlaXZlZCBhIGNvcHkgb2YgdGhlIEdOVSBHZW5lcmFsIFB1YmxpYyBMaWNlbnNlICguL0xJQ0VOU0UpLgoKIEZJTEUgSU5GT1JNQVRJT04KIERFRkdST1VQOiBtb2tvY29uc3VsdGluZy10ZWNoLlRlbXBsYXRlLUpvb21sYQogSU5HUk9VUDogTW9rb1N0YW5kYXJkcy5Hb3Zlcm5hbmNlCiBSRVBPOiBodHRwczovL2dpdGh1Yi5jb20vbW9rb2NvbnN1bHRpbmctdGVjaC9UZW1wbGF0ZS1Kb29tbGEKIFZFUlNJT046IDAxLjAxLjAwCiBQQVRIOiAvR09WRVJOQU5DRS5tZAogQlJJRUY6IFByb2plY3QgZ292ZXJuYW5jZSBydWxlcywgcm9sZXMsIGFuZCBkZWNpc2lvbiBwcm9jZXNzIGZvciBUZW1wbGF0ZS1Kb29tbGEKLS0+CgpbIVtNb2tvU3RhbmRhcmRzXShodHRwczovL2ltZy5zaGllbGRzLmlvL2JhZGdlL01va29TdGFuZGFyZHMtMDQuMDAuMDQtYmx1ZSldKGh0dHBzOi8vZ2l0aHViLmNvbS9tb2tvY29uc3VsdGluZy10ZWNoL01va29TdGFuZGFyZHMpCgojIFByb2plY3QgR292ZXJuYW5jZQoKIyMgT3ZlcnZpZXcKClRoaXMgZG9jdW1lbnQgZGVmaW5lcyB0aGUgZ292ZXJuYW5jZSBtb2RlbCBmb3IgdGhlIGBUZW1wbGF0ZS1Kb29tbGFgIHJlcG9zaXRvcnkgd2l0aGluIHRoZQpgbW9rb2NvbnN1bHRpbmctdGVjaGAgb3JnYW5pemF0aW9uLiBJdCBpcyBhdXRvbWF0aWNhbGx5IG1haW50YWluZWQgYnkKW01va29TdGFuZGFyZHNdKGh0dHBzOi8vZ2l0aHViLmNvbS9tb2tvY29uc3VsdGluZy10ZWNoL01va29TdGFuZGFyZHMpIHYwNC4wMC4wNC4KCkZ1bGwgZ292ZXJuYW5jZSBwb2xpY3kgaXMgZGVmaW5lZCBpbiB0aGUgTW9rb1N0YW5kYXJkcyBzb3VyY2UgcmVwb3NpdG9yeToKW2RvY3MvcG9saWN5L0dPVkVSTkFOQ0UubWRdKGh0dHBzOi8vZ2l0aHViLmNvbS9tb2tvY29uc3VsdGluZy10ZWNoL01va29TdGFuZGFyZHMvYmxvYi9tYWluL2RvY3MvcG9saWN5L0dPVkVSTkFOQ0UubWQpCgotLS0KCiMjIFJvbGVzIGFuZCBSZXNwb25zaWJpbGl0aWVzCgojIyMgTWFpbnRhaW5lcgoKKipHaXRIdWIqKjogQG1va29jb25zdWx0aW5nLXRlY2gKCioqQXV0aG9yaXR5Kio6IEZpbmFsIGRlY2lzaW9uLW1ha2luZyBhdXRob3JpdHkgb24gYWxsIG1hdHRlcnMgZm9yIHRoaXMgcmVwb3NpdG9yeS4KCioqUmVzcG9uc2liaWxpdGllcyoqOgotIFJldmlldyBhbmQgbWVyZ2UgcHVsbCByZXF1ZXN0cwotIE1haW50YWluIGNvZGUgcXVhbGl0eSBhbmQgc3RhbmRhcmRzIGNvbXBsaWFuY2UKLSBNYW5hZ2UgcmVsZWFzZXMgYW5kIHZlcnNpb25pbmcKLSBSZXNwb25kIHRvIGlzc3VlcyBhbmQgc2VjdXJpdHkgcmVwb3J0cwoKIyMjIENvbnRyaWJ1dG9ycwoKKipBdXRob3JpdHkqKjogU3VibWl0IGNoYW5nZXMgdmlhIHB1bGwgcmVxdWVzdHMuCgoqKlJlcXVpcmVtZW50cyoqOgotIFJlYWQgYW5kIGFjY2VwdCBgQ09ERV9PRl9DT05EVUNULm1kYAotIEZvbGxvdyBgQ09OVFJJQlVUSU5HLm1kYCBndWlkZWxpbmVzCgotLS0KCiMjIERlY2lzaW9uLU1ha2luZwoKQWxsIGNoYW5nZXMgbXVzdCBiZSBzdWJtaXR0ZWQgYXMgcHVsbCByZXF1ZXN0cy4gVGhlIG1haW50YWluZXIgKEBtb2tvY29uc3VsdGluZy10ZWNoKQpyZXZpZXdzIGFuZCBhcHByb3ZlcyBhbGwgY2hhbmdlcyBiZWZvcmUgdGhleSBhcmUgbWVyZ2VkLgoKIyMjIFNvbGUgT3BlcmF0b3IgUG9saWN5CgpUaGlzIG9yZ2FuaXphdGlvbiBvcGVyYXRlcyB1bmRlciBhICoqc29sZSBvcGVyYXRvcioqIG1vZGVsLiBUaGUgbWFpbnRhaW5lciAoQG1va29jb25zdWx0aW5nLXRlY2gpCmlzIHRoZSBzb2xlIGVtcGxveWVlIGFuZCBvd25lciBhbmQgbWF5IHNlbGYtYXBwcm92ZSBwdWxsIHJlcXVlc3RzIHdoZW4gbm8gc2Vjb25kIHJldmlld2VyIGlzCmF2YWlsYWJsZS4gVGhlIGZvbGxvd2luZyByZXF1aXJlbWVudHMgcmVtYWluIG1hbmRhdG9yeSByZWdhcmRsZXNzOgoKMS4gKipQdWxsIFJlcXVlc3RzIFJlcXVpcmVkKiog4oCUIGFsbCBjaGFuZ2VzIHRvIHByb3RlY3RlZCBicmFuY2hlcyBnbyB0aHJvdWdoIGEgUFIuCjIuICoqQXV0b21hdGVkIENoZWNrcyoqIOKAlCBhbGwgQ0kgY2hlY2tzIG11c3QgcGFzcyBiZWZvcmUgbWVyZ2luZy4KMy4gKipBdWRpdCBUcmFpbCoqIOKAlCBpc3N1ZXMsIHB1bGwgcmVxdWVzdHMsIGFuZCBjb21taXQgaGlzdG9yeSBhcmUgcHJlc2VydmVkLgo0LiAqKkRvY3VtZW50YXRpb24qKiDigJQgY2hhbmdlcyBhcmUgZG9jdW1lbnRlZCBpbiBgQ0hBTkdFTE9HLm1kYC4KClNlZSB0aGUgZnVsbCBwb2xpY3k6CltTb2xlIE9wZXJhdG9yIFBvbGljeV0oaHR0cHM6Ly9naXRodWIuY29tL21va29jb25zdWx0aW5nLXRlY2gvTW9rb1N0YW5kYXJkcy9ibG9iL21haW4vZG9jcy9wb2xpY3kvR09WRVJOQU5DRS5tZCNzb2xlLW9wZXJhdG9yLXBvbGljeSkKCi0tLQoKIyMgQ2hhbmdlIE1hbmFnZW1lbnQKCnwgQ2hhbmdlIFR5cGUgfCBBcHByb3ZhbCB8IFByb2Nlc3MgfAp8LS0tLS0tLS0tLS0tLXwtLS0tLS0tLS0tfC0tLS0tLS0tLXwKfCBSb3V0aW5lIChkb2NzLCBidWcgZml4ZXMpIHwgTWFpbnRhaW5lciB8IFBSIOKGkiBDSSBwYXNzIOKGkiBtZXJnZSB8CnwgU2lnbmlmaWNhbnQgKG5ldyBmZWF0dXJlcykgfCBNYWludGFpbmVyIHwgUFIgd2l0aCBkZXNjcmlwdGlvbiDihpIgQ0kgcGFzcyDihpIgbWVyZ2UgfAp8IE1ham9yIChicmVha2luZywgYXJjaGl0ZWN0dXJlKSB8IE1haW50YWluZXIgfCBJc3N1ZSBkaXNjdXNzaW9uIOKGkiBQUiDihpIgQ0kgcGFzcyDihpIgbWVyZ2UgfAp8IEVtZXJnZW5jeSAoc2VjdXJpdHkpIHwgTWFpbnRhaW5lciB8IExhYmVsbGVkIGBFTUVSR0VOQ1lgIOKGkiBpbW1lZGlhdGUgbWVyZ2Ug4oaSIHBvc3QtbW9ydGVtIHwKCi0tLQoKIyMgUmVwb3J0aW5nIElzc3VlcwoKLSAqKkJ1Z3MgLyBGZWF0dXJlcyoqOiBPcGVuIGEgW0dpdEh1YiBJc3N1ZV0oaHR0cHM6Ly9naXRodWIuY29tL21va29jb25zdWx0aW5nLXRlY2gvVGVtcGxhdGUtSm9vbWxhL2lzc3VlcykKLSAqKlNlY3VyaXR5IHZ1bG5lcmFiaWxpdGllcyoqOiBTZWUgW1NFQ1VSSVRZLm1kXSguL1NFQ1VSSVRZLm1kKQotICoqQ29kZSBvZiBDb25kdWN0Kio6IFNlZSBbQ09ERV9PRl9DT05EVUNULm1kXSguL0NPREVfT0ZfQ09ORFVDVC5tZCkKLSAqKkNvbnRhY3QqKjogZGV2QG1va29jb25zdWx0aW5nLnRlY2gKCi0tLQoKIyMgTWV0YWRhdGEKCnwgRmllbGQgICAgICAgICB8IFZhbHVlICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHwKfCAtLS0tLS0tLS0tLS0tIHwgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0gfAp8IERvY3VtZW50IFR5cGUgfCBQb2xpY3kgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB8CnwgRG9tYWluICAgICAgICB8IEdvdmVybmFuY2UgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHwKfCBBcHBsaWVzIFRvICAgIHwgbW9rb2NvbnN1bHRpbmctdGVjaC9UZW1wbGF0ZS1Kb29tbGEgICAgICAgICAgICAgICAgICAgICAgICAgICB8CnwgSnVyaXNkaWN0aW9uICB8IFRlbm5lc3NlZSwgVVNBICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHwKfCBNYWludGFpbmVyICAgIHwgQG1va29jb25zdWx0aW5nLXRlY2ggICAgICAgICAgICAgICAgICAgICAgICAgICAgfAp8IFN0YW5kYXJkcyAgICAgfCBNb2tvU3RhbmRhcmRzIHYwNC4wMC4wNCAgICAgICAgICAgIHwKfCBSZXBvICAgICAgICAgIHwgaHR0cHM6Ly9naXRodWIuY29tL21va29jb25zdWx0aW5nLXRlY2gvVGVtcGxhdGUtSm9vbWxhICAgICAgICB8CnwgUGF0aCAgICAgICAgICB8IC9HT1ZFUk5BTkNFLm1kICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHwKfCBTdGF0dXMgICAgICAgIHwgQWN0aXZlIOKAlCBhdXRvLW1haW50YWluZWQgYnkgTW9rb1N0YW5kYXJkcyAgICAgICB8Cg== \ No newline at end of file diff --git a/README.md b/README.md index 7505f75f..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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0fb03011 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,241 @@ + + +# Security Policy + +## Purpose and Scope + +This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues. + +## Supported Versions + +Security updates are provided for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 01.x.x | :white_check_mark: | +| < 01.0 | :x: | + +Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches. + +## Reporting a Vulnerability + +### Where to Report + +**DO NOT** create public GitHub issues for security vulnerabilities. + +Report security vulnerabilities privately to: + +**Email**: `security@mokoconsulting.tech` + +**Subject Line**: `[SECURITY] Template-Joomla - Brief Description` + +### What to Include + +A complete vulnerability report should include: + +1. **Description**: Clear explanation of the vulnerability +2. **Impact**: Potential security impact and severity assessment +3. **Affected Versions**: Which versions are vulnerable +4. **Reproduction Steps**: Detailed steps to reproduce the issue +5. **Proof of Concept**: Code, configuration, or demonstration (if applicable) +6. **Suggested Fix**: Proposed remediation (if known) +7. **Disclosure Timeline**: Your expectations for public disclosure + +### Response Timeline + +* **Initial Response**: Within 3 business days +* **Assessment Complete**: Within 7 business days +* **Fix Timeline**: Depends on severity (see below) +* **Disclosure**: Coordinated with reporter + +## Severity Classification + +Vulnerabilities are classified using the following severity levels: + +### Critical +* Remote code execution +* Authentication bypass +* Data breach or exposure of sensitive information +* **Fix Timeline**: 7 days + +### High +* Privilege escalation +* SQL injection or command injection +* Cross-site scripting (XSS) with significant impact +* **Fix Timeline**: 14 days + +### Medium +* Information disclosure (limited scope) +* Denial of service +* Security misconfigurations with moderate impact +* **Fix Timeline**: 30 days + +### Low +* Security best practice violations +* Minor information leaks +* Issues requiring user interaction or complex preconditions +* **Fix Timeline**: 60 days or next release + +## Remediation Process + +1. **Acknowledgment**: Security team confirms receipt and begins investigation +2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed +3. **Development**: Security patch is developed and tested +4. **Review**: Patch undergoes security review and validation +5. **Release**: Fixed version is released with security advisory +6. **Disclosure**: Public disclosure follows coordinated timeline + +## Security Advisories + +Security advisories are published via: + +* GitHub Security Advisories +* Release notes and CHANGELOG.md +* Email notification to project users (if mailing list is established) + +Advisories include: + +* CVE identifier (if applicable) +* Severity rating +* Affected versions +* Fixed versions +* Mitigation steps +* Attribution (with reporter consent) + +## Security Best Practices + +For projects using this template: + +### Required Controls + +* Enable GitHub security features (Dependabot, code scanning) +* Implement branch protection on `main` +* Require code review for all changes +* Enforce signed commits (recommended) +* Use secrets management (never commit credentials) +* Maintain security documentation +* Follow secure coding standards defined in MokoStandards + +### Joomla Plugin Security + +* Follow Joomla security best practices +* Validate and sanitize all user input +* Use Joomla's database API to prevent SQL injection +* Properly escape output to prevent XSS +* Implement proper access control checks +* Use Joomla's session and authentication APIs +* Keep Joomla and dependencies up to date + +### CI/CD Security + +* Validate all inputs +* Sanitize outputs +* Use least privilege access +* Pin dependencies with hash verification +* Scan for vulnerabilities in dependencies +* Audit third-party actions and tools + +#### Automated Security Scanning + +All repositories SHOULD implement: + +**CodeQL Analysis**: +* Enabled for PHP and other supported languages +* Runs on: push to main, pull requests, weekly schedule +* Query sets: `security-extended` and `security-and-quality` +* Configuration: `.github/workflows/codeql-analysis.yml` + +**Dependabot Security Updates**: +* Weekly scans for vulnerable dependencies +* Automated pull requests for security patches +* Configuration: `.github/dependabot.yml` + +**Secret Scanning**: +* Enabled by default with push protection +* Prevents accidental credential commits + +### Dependency Management + +* Keep dependencies up to date +* Monitor security advisories for dependencies +* Remove unused dependencies +* Audit new dependencies before adoption +* Document security-critical dependencies + +## Compliance and Governance + +This security policy is aligned with MokoStandards. Deviations require documented justification. + +Security policies are reviewed and updated at least annually or following significant security incidents. + +## Attribution and Recognition + +We acknowledge and appreciate responsible disclosure. With your permission, we will: + +* Credit you in security advisories +* List you in CHANGELOG.md for the fix release +* Recognize your contribution publicly (if desired) + +## Contact and Escalation + +* **Security Team**: security@mokoconsulting.tech +* **Primary Contact**: hello@mokoconsulting.tech +* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub + +## Out of Scope + +The following are explicitly out of scope: + +* Issues in third-party dependencies (report directly to maintainers) +* Social engineering attacks +* Physical security issues +* Denial of service via resource exhaustion without amplification +* Issues requiring physical access to systems +* Theoretical vulnerabilities without proof of exploitability + +--- + +## Metadata + +| Field | Value | +| ------------ | ------------------------------------------------------------------------------------------------------------ | +| Document | Security Policy | +| Path | /SECURITY.md | +| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) | +| Owner | Moko Consulting | +| Scope | Security vulnerability handling | +| Status | Active | +| Effective | 2026-01-16 | + +## Revision History + +| Date | Change Description | Author | +| ---------- | ------------------------------------------------- | --------------- | +| 2026-01-16 | Initial creation for template repository | Moko Consulting | diff --git a/composer.json b/composer.json index 99388c72..3cab6524 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,23 @@ "php": ">=8.1" }, "require-dev": { + "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3.7", "phpstan/phpstan": "^1.10", - "joomla/coding-standards": "^4.0" + "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/" + } }, "config": { "sort-packages": true diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..3d4adca9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,32 @@ +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# +# PHPStan configuration for Joomla extension repositories. +# Extends the base MokoStandards config and adds Joomla framework class stubs +# so PHPStan can resolve Factory, CMSApplication, User, Table, etc. +# without requiring a full Joomla installation. + +parameters: + level: 5 + + paths: + - src + + excludePaths: + - vendor + - node_modules + + # Joomla framework stubs — resolved via the enterprise package from vendor/ + stubFiles: + - vendor/mokoconsulting-tech/enterprise/templates/stubs/joomla.php + + # Suppress errors that are structural in Joomla's service-container architecture + ignoreErrors: + # Joomla's service-based dependency injection returns mixed from getApplication() + - '#Cannot call method .+ on Joomla\\CMS\\Application\\CMSApplication\|null#' + # Factory::getX() patterns are safe at runtime even when nullable in stubs + - '#Call to static method [a-zA-Z]+\(\) on an interface#' + + reportUnmatchedIgnoredErrors: false + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false 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.07.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 6c538fa2..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.07.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 b888a0c3..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.07.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 8ea82929..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.07.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 67b927a3..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.07.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 20065890..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.07.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 4b91a045..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.07.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 66c5e98f..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.07.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 871ba76b..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.07.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 5d5c9c55..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.07.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 b0699f54..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.07.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 49862477..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.07.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 1aa17ed0..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.07.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 201214e9..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.07.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 d259ce6f..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.07.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 a416de87..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.07.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 8d9e1ced..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.07.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 3820518e..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.07.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 c621500c..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.07.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 1ce1eae9..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.07.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 8d8fd63c..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.07.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 2c798c16..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.07.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 c25a3dd2..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.07.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 9c5aefd9..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.07.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 df2c6cb2..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.07.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 c8475276..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.07.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 fc8e9358..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.07.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 44c917fa..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.07.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 30dd7eda..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.07.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 e9e3c525..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.07.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 e63da3fc..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.07.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 4fa57a30..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.07.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 a41156a2..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.07.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 599a7587..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.07.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 3e944a08..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.07.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 6fc4da59..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.07.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 eefaea64..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.07.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 ce84295f..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.07.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 493ef988..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.07.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 99ac0d81..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.07.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 a40d141c..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.07.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 2a4b5ac1..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.07.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 2fcb298d..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.07.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 50c81c4f..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.07.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 29425c22..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.07.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 583f34b5..5eaa2479 100644 --- a/source/pkg_mokosuitecross.xml +++ b/source/pkg_mokosuitecross.xml @@ -2,7 +2,7 @@ MokoSuiteCross mokosuitecross - 01.07.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('