Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6916526e67 | |||
| ab65658f12 | |||
| 0c2074f801 | |||
| e27b958712 | |||
| a169ea4967 | |||
| d951d86b3a | |||
| b03c7c6ba7 | |||
| 1c15497c32 | |||
| 9e38609fe9 | |||
| b907b778c0 | |||
| 4d758890a8 | |||
| 824b4d9ecd | |||
| 307eb7741d | |||
| 4a13ea6ade | |||
| bcc17e4882 | |||
| 4ce96dc95b | |||
| 99e4a83ed5 | |||
| 63c4fbcd14 | |||
| 15a03b309b | |||
| a537132836 | |||
| 6f29c077e2 | |||
| 9fa2560ce4 | |||
| 45afb1f0b1 | |||
| 843c729828 | |||
| db061e2b75 | |||
| a6dc736787 | |||
| a247a5fd0e | |||
| e0c95b4291 | |||
| decb1ba8b7 | |||
| 290284a0c9 | |||
| c9eff72278 | |||
| a86686c30a | |||
| bf8cc9bd0a | |||
| bed05630ca | |||
| 831223f7bc | |||
| 0428904ae8 | |||
| 4cfde99e7f | |||
| 1e105d6c7b | |||
| 2140c9e07f | |||
| 5cb6dd8008 | |||
| 2264b00828 | |||
| f87086bd0f | |||
| fc23c771c0 | |||
| 96299a6b9a | |||
| 1961585e83 | |||
| 14f5407820 | |||
| 407b30a437 | |||
| 49041565eb | |||
| 1d1026f7e7 | |||
| 1f7329272d | |||
| 4c855ac7c8 | |||
| a350d02d08 | |||
| a860d414bd | |||
| e03c86f2c6 | |||
| 1b0025e55f | |||
| ffe599ee92 | |||
| ac56b3a776 | |||
| 95badba96e | |||
| de66983cda | |||
| 89a59f8a8e | |||
| 34367ae93c | |||
| b2d4071193 | |||
| 3bc5678768 | |||
| ca0cfd9a6d | |||
| 9cc4b90b78 | |||
| 4ebb9e30d6 | |||
| 0f164b607c | |||
| 6762764006 | |||
| efdcaa712f | |||
| d3581564cf | |||
| a29d8f4e12 | |||
| 109ca703ef | |||
| 794746e20d | |||
| 85848c2d6c | |||
| 86d4681fcd | |||
| 0a14a29ac6 | |||
| df07b4b672 | |||
| 7bd151ad62 | |||
| ddc867ad06 | |||
| a111f5b5e9 | |||
| 1897805483 | |||
| 8919db6fc3 | |||
| d69b26af51 | |||
| a8dae85f42 | |||
| d3bc62f810 | |||
| 13683adfba | |||
| e183b62aba | |||
| ce9d72b50d | |||
| 92358a673b | |||
| 99308cd7a4 | |||
| 561ba24090 | |||
| 3e1cb9a500 | |||
| 5ae8e3e001 | |||
| faea3637e0 | |||
| 79eaa5217d | |||
| 0e0891f1a8 | |||
| 33aaf666ae | |||
| a634938799 | |||
| 14ff4ab2f1 | |||
| b3de21e7d1 | |||
| 72a373b17c | |||
| bc290f3bed | |||
| a4704ad267 | |||
| d1762ad5df | |||
| df1467c518 | |||
| 7cdd97ca59 | |||
| 5b36d10b04 | |||
| 56699fdd4d | |||
| fcf1cc41c8 | |||
| b8640ccb1d | |||
| 4b51e2dd9a | |||
| e068e14004 | |||
| 941fd4c6cd | |||
| f2021d478e | |||
| ca06298e64 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Documentation Issue
|
||||
about: Report an issue with documentation
|
||||
title: '[DOCS] '
|
||||
labels: 'documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Documentation Issue
|
||||
|
||||
**Location**:
|
||||
<!-- Specify the file, page, or section with the issue -->
|
||||
|
||||
## Issue Type
|
||||
<!-- Mark the relevant option with an "x" -->
|
||||
- [ ] Typo or grammar error
|
||||
- [ ] Outdated information
|
||||
- [ ] Missing documentation
|
||||
- [ ] Unclear explanation
|
||||
- [ ] Broken links
|
||||
- [ ] Missing examples
|
||||
- [ ] Other (specify below)
|
||||
|
||||
## Description
|
||||
<!-- Clearly describe the documentation issue -->
|
||||
|
||||
## Current Content
|
||||
<!-- Quote or describe the current documentation (if applicable) -->
|
||||
```
|
||||
Current text here
|
||||
```
|
||||
|
||||
## Suggested Improvement
|
||||
<!-- Provide your suggestion for how to improve the documentation -->
|
||||
```
|
||||
Suggested text here
|
||||
```
|
||||
|
||||
## Additional Context
|
||||
<!-- Add any other context, screenshots, or references -->
|
||||
|
||||
## 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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**:
|
||||
<!-- Low, Medium, or informational only -->
|
||||
|
||||
## Description
|
||||
<!-- Describe the security concern or improvement suggestion -->
|
||||
|
||||
## Affected Components
|
||||
<!-- List the affected files, features, or components -->
|
||||
|
||||
## Suggested Mitigation
|
||||
<!-- Describe how this could be addressed -->
|
||||
|
||||
## 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
|
||||
<!-- Add any other context about the security concern -->
|
||||
|
||||
## Checklist
|
||||
- [ ] This is NOT a critical vulnerability requiring private disclosure
|
||||
- [ ] I have reviewed the SECURITY.md policy
|
||||
- [ ] I have provided sufficient detail for evaluation
|
||||
@@ -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**: <!-- e.g., 01.02.03 -->
|
||||
**Requested version**: <!-- e.g., 01.03.00 -->
|
||||
**Change type**: <!-- patch / minor / major -->
|
||||
|
||||
## Reason
|
||||
|
||||
<!-- Why is this version bump needed? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] README.md `VERSION:` field updated
|
||||
- [ ] CHANGELOG.md entry added
|
||||
- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: `<version>`)
|
||||
- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||
|
||||
name: "Universal: CI Issue Reporter"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
gate:
|
||||
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||
required: true
|
||||
type: string
|
||||
details:
|
||||
description: "Human-readable failure description"
|
||||
required: true
|
||||
type: string
|
||||
severity:
|
||||
description: "error or warning"
|
||||
required: false
|
||||
type: string
|
||||
default: "error"
|
||||
workflow:
|
||||
description: "Workflow name for the issue title"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
secrets:
|
||||
MOKOGITEA_TOKEN:
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: "Report: ${{ inputs.gate }}"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone MokoCLI
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||
|
||||
- name: Report CI failure
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
INPUT_GATE: ${{ inputs.gate }}
|
||||
INPUT_DETAILS: ${{ inputs.details }}
|
||||
INPUT_SEVERITY: ${{ inputs.severity }}
|
||||
INPUT_WORKFLOW: ${{ inputs.workflow }}
|
||||
run: |
|
||||
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||
--gate "$INPUT_GATE" \
|
||||
--details "$INPUT_DETAILS" \
|
||||
--severity "$INPUT_SEVERITY" \
|
||||
--workflow "$INPUT_WORKFLOW"
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.07.00
|
||||
# VERSION: 01.08.54
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -20,6 +20,7 @@ on:
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- 'chore/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
@@ -87,8 +88,20 @@ jobs:
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
@@ -165,6 +178,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -175,6 +189,7 @@ jobs:
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -211,6 +226,7 @@ jobs:
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
@@ -224,6 +240,7 @@ jobs:
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -45,13 +45,6 @@ jobs:
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform: ${PLATFORM:-all}"
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Clone mokocli
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Best time to post analytics**: engagement tracking with heatmap dashboard widget (#165)
|
||||
- **Analytics heatmap**: 7x24 day/hour grid showing optimal posting windows per platform
|
||||
- **Analytics recommendations**: top posting times based on historical engagement data
|
||||
- **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)
|
||||
|
||||
### Fixed
|
||||
- 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 +48,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 +90,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
|
||||
|
||||
|
||||
<!-- VERSION: 01.08.54 -->
|
||||
|
||||
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/).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
|
||||
VERSION: 01.08.54
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Community expectations and enforcement guidelines
|
||||
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
||||
-->
|
||||
|
||||
# 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.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteCross
|
||||
|
||||
<!-- VERSION: 01.07.00 -->
|
||||
<!-- VERSION: 01.08.54 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Template-Joomla
|
||||
INGROUP: Template-Joomla.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 01.08.54
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
# 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 |
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# 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
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>source/packages/com_mokosuitecross/src</directory>
|
||||
<directory>source/packages/plg_content_mokosuitecross/src</directory>
|
||||
<directory>source/packages/plg_system_mokosuitecross/src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -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."
|
||||
|
||||
@@ -120,6 +120,42 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="link_shortening" label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING">
|
||||
<field
|
||||
name="link_shortener"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC"
|
||||
default="none">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE</option>
|
||||
<option value="bitly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY</option>
|
||||
<option value="rebrandly">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY</option>
|
||||
<option value="yourls">COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS</option>
|
||||
</field>
|
||||
<field
|
||||
name="link_shortener_api_key"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC"
|
||||
showon="link_shortener:bitly,rebrandly"
|
||||
/>
|
||||
<field
|
||||
name="link_shortener_yourls_url"
|
||||
type="url"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC"
|
||||
hint="https://short.example.com/yourls-api.php"
|
||||
showon="link_shortener:yourls"
|
||||
/>
|
||||
<field
|
||||
name="link_shortener_yourls_token"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC"
|
||||
showon="link_shortener:yourls"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
|
||||
<field
|
||||
name="evergreen_enabled"
|
||||
@@ -191,6 +227,79 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ai" label="COM_MOKOSUITECROSS_CONFIG_AI">
|
||||
<field
|
||||
name="ai_provider"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC"
|
||||
default="none">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE</option>
|
||||
<option value="claude">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE</option>
|
||||
<option value="openai">COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI</option>
|
||||
</field>
|
||||
<field
|
||||
name="ai_api_key"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_model"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_MODEL"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC"
|
||||
hint="claude-haiku-4-5 / gpt-4o-mini"
|
||||
showon="ai_provider:claude,openai"
|
||||
/>
|
||||
<field
|
||||
name="ai_tone"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_AI_TONE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC"
|
||||
default="professional"
|
||||
showon="ai_provider:claude,openai">
|
||||
<option value="professional">COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL</option>
|
||||
<option value="friendly">COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY</option>
|
||||
<option value="casual">COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="social_image" label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE">
|
||||
<field
|
||||
name="social_image_bg_color"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
|
||||
default="#1a1a2e"
|
||||
/>
|
||||
<field
|
||||
name="social_image_text_color"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
|
||||
default="#ffffff"
|
||||
/>
|
||||
<field
|
||||
name="social_image_overlay"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DESC"
|
||||
default="dark">
|
||||
<option value="none">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_NONE</option>
|
||||
<option value="light">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_LIGHT</option>
|
||||
<option value="dark">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DARK</option>
|
||||
</field>
|
||||
<field
|
||||
name="social_image_site_name"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME_DESC"
|
||||
hint="Leave blank to use Joomla site name"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
|
||||
<field
|
||||
name="category_rules_note"
|
||||
|
||||
@@ -534,6 +534,66 @@ COM_MOKOSUITECROSS_DISPATCH_INVALID_SERVICES="service_ids must be a non-empty ar
|
||||
COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found."
|
||||
COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request."
|
||||
|
||||
; Link Shortening
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENING="Link Shortening"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER="Link Shortener"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_DESC="Select a link shortening service. Shortened URLs are available via the {url_short} placeholder in templates."
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_NONE="None (disabled)"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_BITLY="Bitly"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_REBRANDLY="Rebrandly"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS="YOURLS (self-hosted)"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY="API Key"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_API_KEY_DESC="API key for Bitly or Rebrandly."
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL="YOURLS API URL"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_URL_DESC="Full URL to your YOURLS API endpoint (e.g. https://short.example.com/yourls-api.php)."
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN="YOURLS Signature Token"
|
||||
COM_MOKOSUITECROSS_CONFIG_LINK_SHORTENER_YOURLS_TOKEN_DESC="Secret signature token from your YOURLS installation."
|
||||
|
||||
; AI Caption Generation
|
||||
COM_MOKOSUITECROSS_CONFIG_AI="AI Caption Generation"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER="AI Provider"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_DESC="Select an AI provider to generate cross-post captions from article content. The API key is stored in Joomla component params (encrypted at rest)."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_NONE="None (disabled)"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_CLAUDE="Anthropic Claude"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_PROVIDER_OPENAI="OpenAI"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY="API Key"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_API_KEY_DESC="API key for the selected AI provider."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_MODEL="Model"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_MODEL_DESC="AI model to use. Leave blank for the default (Claude Haiku 4.5 or GPT-4o Mini)."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE="Tone"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_DESC="The writing tone for generated captions."
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_PROFESSIONAL="Professional"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_FRIENDLY="Friendly"
|
||||
COM_MOKOSUITECROSS_CONFIG_AI_TONE_CASUAL="Casual"
|
||||
COM_MOKOSUITECROSS_AI_GENERATE="Generate with AI"
|
||||
COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from the article content using AI."
|
||||
COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..."
|
||||
COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully."
|
||||
COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s"
|
||||
|
||||
; Social Image Generator
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE="Social Image Generator"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR="Background Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC="Default background color for generated OG images when no article image is available."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR="Text Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Color for the title and site name text overlay on generated images."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY="Image Overlay"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DESC="Darken or lighten the background image to improve text readability."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_NONE="None"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_LIGHT="Light"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DARK="Dark"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME="Site Name Override"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME_DESC="Custom site name shown at the bottom of generated images. Leave blank to use the Joomla site name."
|
||||
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
|
||||
|
||||
|
||||
; Analytics
|
||||
COM_MOKOSUITECROSS_ANALYTICS="Analytics"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough data yet. Analytics will appear after posts collect engagement metrics."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE="Engagement Rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS="All Platforms"
|
||||
; Category Rules
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
|
||||
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokosuitecross</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
; MokoSuiteCross — Site Frontend Language File
|
||||
; MokoSuiteCross -- Site Frontend Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; 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."
|
||||
|
||||
@@ -17,5 +17,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'post';
|
||||
protected $default_view = 'posts';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class PostModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getArticle(int $articleId): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('a.id, a.title, a.alias, a.catid, a.access')
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->where('a.id = ' . (int) $articleId)
|
||||
->where('a.state = 1');
|
||||
|
||||
$groups = $user->getAuthorisedViewLevels();
|
||||
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObject() ?: null;
|
||||
}
|
||||
|
||||
public function getPosts(int $articleId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
'p.id',
|
||||
'p.status',
|
||||
'p.platform_post_id',
|
||||
'p.posted_at',
|
||||
'p.error_message',
|
||||
'p.created',
|
||||
's.title AS service_title',
|
||||
's.service_type',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
|
||||
->where('p.article_id = ' . (int) $articleId)
|
||||
->order('p.created DESC');
|
||||
|
||||
$db->setQuery($query, 0, 50);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
|
||||
class PostsModel extends ListModel
|
||||
{
|
||||
protected function getListQuery()
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select([
|
||||
'a.id AS article_id',
|
||||
'a.title AS article_title',
|
||||
'a.alias AS article_alias',
|
||||
'a.catid',
|
||||
'MAX(p.posted_at) AS last_posted',
|
||||
'COUNT(p.id) AS post_count',
|
||||
'GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR \',\') AS service_types',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
|
||||
->join('INNER', $db->quoteName('#__content', 'a') . ' ON a.id = p.article_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON s.id = p.service_id')
|
||||
->where('p.status = ' . $db->quote('posted'))
|
||||
->where('a.state = 1');
|
||||
|
||||
// Access filtering
|
||||
$groups = $user->getAuthorisedViewLevels();
|
||||
$query->where('a.access IN (' . implode(',', array_map('intval', $groups)) . ')');
|
||||
|
||||
$query->group('a.id, a.title, a.alias, a.catid')
|
||||
->order('last_posted DESC');
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Site\View\Post;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $article;
|
||||
protected $posts;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$articleId = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$model = $this->getModel();
|
||||
$this->article = $model->getArticle($articleId);
|
||||
$this->posts = $model->getPosts($articleId);
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Site\View\Posts;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
$statusClasses = [
|
||||
'posted' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
'permanently_failed' => 'bg-danger',
|
||||
'queued' => 'bg-warning text-dark',
|
||||
'posting' => 'bg-info',
|
||||
'scheduled' => 'bg-primary',
|
||||
'deleted' => 'bg-secondary',
|
||||
'cancelled' => 'bg-secondary',
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="com-mokosuitecross-post">
|
||||
<?php if (!$this->article) : ?>
|
||||
<div class="alert alert-warning">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POST_DETAIL_TITLE'); ?></h2>
|
||||
<p>
|
||||
<strong><?php echo $this->escape($this->article->title); ?></strong>
|
||||
</p>
|
||||
|
||||
<?php if (empty($this->posts)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_HEADING_SERVICE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_STATUS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_POSTED_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LINK'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->posts as $post) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($post->service_type); ?></span>
|
||||
<?php echo $this->escape($post->service_title); ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?php echo $statusClasses[$post->status] ?? 'bg-secondary'; ?>">
|
||||
<?php echo $this->escape(ucfirst($post->status)); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo $post->posted_at ? $this->escape($post->posted_at) : $this->escape($post->created); ?></td>
|
||||
<td>
|
||||
<?php if (!empty($post->platform_post_id)) : ?>
|
||||
<span class="text-muted small"><?php echo $this->escape($post->platform_post_id); ?></span>
|
||||
<?php else : ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=posts'); ?>" class="btn btn-secondary">
|
||||
← <?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
?>
|
||||
<div class="com-mokosuitecross-posts">
|
||||
<h2><?php echo Text::_('COM_MOKOSUITECROSS_POSTS_LIST_TITLE'); ?></h2>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_NO_POSTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_ARTICLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_PLATFORMS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOSUITECROSS_COLUMN_LAST_POSTED'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $item) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=post&id=' . (int) $item->article_id); ?>">
|
||||
<?php echo $this->escape($item->article_title); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$types = explode(',', $item->service_types ?? '');
|
||||
foreach ($types as $type) :
|
||||
$type = trim($type);
|
||||
if (empty($type)) continue;
|
||||
?>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $item->last_posted ? $this->escape($item->last_posted) : '—'; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($this->pagination->pagesTotal > 1) : ?>
|
||||
<div class="com-mokosuitecross-posts__pagination">
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -96,6 +96,27 @@ INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_bo
|
||||
('instagram', 'Instagram Default', '{social}\n\n{hashtags}', 1, 21, NOW()),
|
||||
('youtube', 'YouTube Default', '{social}\n\n{url}', 1, 22, NOW());
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`post_id` int unsigned NOT NULL,
|
||||
`service_id` int unsigned NOT NULL,
|
||||
`service_type` varchar(50) NOT NULL DEFAULT '',
|
||||
`posted_at` datetime DEFAULT NULL,
|
||||
`day_of_week` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`hour_of_day` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`impressions` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagements` int unsigned NOT NULL DEFAULT 0,
|
||||
`clicks` int unsigned NOT NULL DEFAULT 0,
|
||||
`shares` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`created` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_service_type` (`service_type`),
|
||||
KEY `idx_day_hour` (`day_of_week`, `hour_of_day`),
|
||||
KEY `idx_post` (`post_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`category_id` int(10) unsigned NOT NULL,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.05 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.07 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.08 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.09 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.10 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.11 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.12 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.13 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.14 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.15 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.16 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.17 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.19 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.20 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.21 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.22 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.23 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.24 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.25 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.26 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.27 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.28 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.29 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.30 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.31 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.32 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.33 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.34 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.35 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.36 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.37 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.38 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.39 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.40 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.41 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.43 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.44 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.45 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.46 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.47 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.49 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.50 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.51 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.52 — no schema changes */
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.08.53 — no schema changes */
|
||||
@@ -0,0 +1,23 @@
|
||||
-- MokoSuiteCross 01.08.54 -- Best time to post analytics
|
||||
-- Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`post_id` int unsigned NOT NULL,
|
||||
`service_id` int unsigned NOT NULL,
|
||||
`service_type` varchar(50) NOT NULL DEFAULT '',
|
||||
`posted_at` datetime DEFAULT NULL,
|
||||
`day_of_week` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`hour_of_day` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
`impressions` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagements` int unsigned NOT NULL DEFAULT 0,
|
||||
`clicks` int unsigned NOT NULL DEFAULT 0,
|
||||
`shares` int unsigned NOT NULL DEFAULT 0,
|
||||
`engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`created` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_service_type` (`service_type`),
|
||||
KEY `idx_day_hour` (`day_of_week`, `hour_of_day`),
|
||||
KEY `idx_post` (`post_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AiGeneratorHelper;
|
||||
|
||||
class AiController extends BaseController
|
||||
{
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.edit', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'introtext', 'catid']))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$category = '';
|
||||
$catQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($catQuery);
|
||||
$category = $db->loadResult() ?: '';
|
||||
|
||||
$tagQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('t.title'))
|
||||
->from($db->quoteName('#__tags', 't'))
|
||||
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
|
||||
->where($db->quoteName('m.content_item_id') . ' = ' . $articleId)
|
||||
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'));
|
||||
$db->setQuery($tagQuery);
|
||||
$tags = $db->loadColumn() ?: [];
|
||||
|
||||
$introtext = strip_tags($article->introtext ?? '');
|
||||
$introtext = mb_substr($introtext, 0, 500);
|
||||
|
||||
$params = \Joomla\CMS\Component\ComponentHelper::getParams('com_mokosuitecross');
|
||||
|
||||
$config = [
|
||||
'ai_provider' => $params->get('ai_provider', 'none'),
|
||||
'ai_api_key' => $params->get('ai_api_key', ''),
|
||||
'ai_model' => $params->get('ai_model', ''),
|
||||
'ai_tone' => $params->get('ai_tone', 'professional'),
|
||||
];
|
||||
|
||||
$result = AiGeneratorHelper::generate($article->title, $introtext, $category, $tags, $config);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
|
||||
|
||||
class AnalyticsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Return heatmap grid data as JSON.
|
||||
*
|
||||
* Query params: service_type (string), days (int, default 90)
|
||||
*/
|
||||
public function heatmap(): 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;
|
||||
}
|
||||
|
||||
$serviceType = $this->input->getCmd('service_type', '');
|
||||
$days = $this->input->getInt('days', 90);
|
||||
|
||||
$grid = AnalyticsHelper::getHeatmapData($serviceType, $days);
|
||||
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, 3);
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'grid' => $grid,
|
||||
'best_times' => $bestTimes,
|
||||
]);
|
||||
$this->app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the top posting times as JSON.
|
||||
*
|
||||
* Query params: service_type (string), limit (int, default 5)
|
||||
*/
|
||||
public function besttimes(): 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;
|
||||
}
|
||||
|
||||
$serviceType = $this->input->getCmd('service_type', '');
|
||||
$limit = $this->input->getInt('limit', 5);
|
||||
|
||||
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, $limit);
|
||||
$serviceBreakdown = AnalyticsHelper::getServiceBreakdown();
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'best_times' => $bestTimes,
|
||||
'service_breakdown' => $serviceBreakdown,
|
||||
]);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\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;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$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 .= '<div style="margin-bottom:20px;">'
|
||||
. '<div style="font-weight:600;font-size:13px;color:#666;margin-bottom:6px;text-transform:uppercase;">' . htmlspecialchars(ucfirst($p)) . '</div>'
|
||||
. PreviewHelper::render($p, $title, $text, $url, $imageUrl, $authorName)
|
||||
. '</div>';
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\SocialImageHelper;
|
||||
|
||||
class SocialImageController extends BaseController
|
||||
{
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'images']))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$siteName = $params->get('social_image_site_name', '') ?: Factory::getApplication()->get('sitename', '');
|
||||
|
||||
$options = [
|
||||
'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
|
||||
'text_color' => $params->get('social_image_text_color', '#ffffff'),
|
||||
'overlay' => $params->get('social_image_overlay', 'dark'),
|
||||
];
|
||||
|
||||
$backgroundPath = null;
|
||||
$images = json_decode($article->images ?? '{}', true);
|
||||
|
||||
if (!empty($images['image_intro'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_intro'], '/');
|
||||
} elseif (!empty($images['image_fulltext'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_fulltext'], '/');
|
||||
}
|
||||
|
||||
try {
|
||||
$imagePath = SocialImageHelper::generate($article->title, $siteName, $backgroundPath, $options);
|
||||
$imageUrl = str_replace(JPATH_ROOT, Uri::root(true), str_replace('\\', '/', $imagePath));
|
||||
|
||||
$result = ['success' => true, 'image_url' => $imageUrl, 'image_path' => $imagePath];
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class AiGeneratorHelper
|
||||
{
|
||||
public static function generate(string $title, string $introtext, string $category, array $tags, array $config): array
|
||||
{
|
||||
$provider = $config['ai_provider'] ?? 'none';
|
||||
$apiKey = $config['ai_api_key'] ?? '';
|
||||
$model = $config['ai_model'] ?? '';
|
||||
$tone = $config['ai_tone'] ?? 'professional';
|
||||
|
||||
if ($provider === 'none' || $apiKey === '') {
|
||||
return ['success' => false, 'error' => 'AI provider not configured or API key missing.'];
|
||||
}
|
||||
|
||||
$prompt = self::buildPrompt($title, $introtext, $category, $tags, $tone);
|
||||
|
||||
$response = match ($provider) {
|
||||
'claude' => self::callClaude($prompt, $apiKey, $model ?: 'claude-haiku-4-5'),
|
||||
'openai' => self::callOpenAI($prompt, $apiKey, $model ?: 'gpt-4o-mini'),
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($response === '') {
|
||||
return ['success' => false, 'error' => 'AI provider returned an empty response.'];
|
||||
}
|
||||
|
||||
$parsed = self::parseResponse($response);
|
||||
|
||||
if ($parsed === null) {
|
||||
return ['success' => false, 'error' => 'Could not parse AI response as JSON.'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $parsed];
|
||||
}
|
||||
|
||||
private static function callClaude(string $prompt, string $apiKey, string $model): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 500,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
curl_close($ch);
|
||||
return '';
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['content'][0]['text'] ?? '';
|
||||
}
|
||||
|
||||
private static function callOpenAI(string $prompt, string $apiKey, string $model): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 500,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
curl_close($ch);
|
||||
return '';
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['choices'][0]['message']['content'] ?? '';
|
||||
}
|
||||
|
||||
private static function buildPrompt(string $title, string $introtext, string $category, array $tags, string $tone): string
|
||||
{
|
||||
$tagList = !empty($tags) ? implode(', ', $tags) : 'none';
|
||||
|
||||
$toneGuide = match ($tone) {
|
||||
'casual' => 'Use a relaxed, conversational tone.',
|
||||
'friendly' => 'Use a warm, approachable tone with enthusiasm.',
|
||||
default => 'Use a professional, polished tone.',
|
||||
};
|
||||
|
||||
return <<<PROMPT
|
||||
Generate cross-post captions for this article. {$toneGuide}
|
||||
|
||||
Article title: {$title}
|
||||
Content summary: {$introtext}
|
||||
Category: {$category}
|
||||
Tags: {$tagList}
|
||||
|
||||
Return ONLY a JSON object with these keys (no markdown, no explanation):
|
||||
{
|
||||
"social": "Facebook/LinkedIn post (max 200 chars, include a call to action)",
|
||||
"short": "Twitter/Bluesky post (max 270 chars, punchy, include 1-2 relevant hashtags)",
|
||||
"chat": "Telegram/Discord message (max 300 chars, conversational)",
|
||||
"email_subject": "Email subject line (max 60 chars, compelling, no clickbait)"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Do not include the article URL (it is added automatically)
|
||||
- Do not wrap the JSON in markdown code fences
|
||||
- Respect the character limits strictly
|
||||
- Each caption should be unique, not just a reformatted version of the others
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private static function parseResponse(string $response): ?array
|
||||
{
|
||||
$response = trim($response);
|
||||
|
||||
if (preg_match('/\{[\s\S]*\}/', $response, $matches)) {
|
||||
$response = $matches[0];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
if (!\is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$required = ['social', 'short', 'chat', 'email_subject'];
|
||||
|
||||
foreach ($required as $key) {
|
||||
if (!isset($data[$key]) || !\is_string($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'social' => mb_substr($data['social'], 0, 500),
|
||||
'short' => mb_substr($data['short'], 0, 280),
|
||||
'chat' => mb_substr($data['chat'], 0, 500),
|
||||
'email_subject' => mb_substr($data['email_subject'], 0, 120),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class AnalyticsHelper
|
||||
{
|
||||
/**
|
||||
* Record or update engagement metrics for a post.
|
||||
*
|
||||
* @param int $postId The post ID
|
||||
* @param int $serviceId The service ID
|
||||
* @param string $serviceType The service type (e.g. twitter, facebook)
|
||||
* @param array $metrics Engagement metrics: impressions, engagements, clicks, shares, posted_at
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$postedAt = $metrics['posted_at'] ?? null;
|
||||
|
||||
if ($postedAt) {
|
||||
$timestamp = strtotime($postedAt);
|
||||
$dayOfWeek = (int) date('w', $timestamp);
|
||||
$hourOfDay = (int) date('G', $timestamp);
|
||||
} else {
|
||||
$dayOfWeek = 0;
|
||||
$hourOfDay = 0;
|
||||
}
|
||||
|
||||
$impressions = (int) ($metrics['impressions'] ?? 0);
|
||||
$engagements = (int) ($metrics['engagements'] ?? 0);
|
||||
$clicks = (int) ($metrics['clicks'] ?? 0);
|
||||
$shares = (int) ($metrics['shares'] ?? 0);
|
||||
|
||||
$engagementRate = $impressions > 0
|
||||
? round(($engagements / $impressions) * 100, 2)
|
||||
: 0.00;
|
||||
|
||||
// Check if a row already exists for this post
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->where($db->quoteName('post_id') . ' = ' . $postId)
|
||||
->where($db->quoteName('service_id') . ' = ' . $serviceId);
|
||||
$db->setQuery($query);
|
||||
$existingId = $db->loadResult();
|
||||
|
||||
if ($existingId) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->set($db->quoteName('impressions') . ' = ' . $impressions)
|
||||
->set($db->quoteName('engagements') . ' = ' . $engagements)
|
||||
->set($db->quoteName('clicks') . ' = ' . $clicks)
|
||||
->set($db->quoteName('shares') . ' = ' . $shares)
|
||||
->set($db->quoteName('engagement_rate') . ' = ' . $engagementRate)
|
||||
->where($db->quoteName('id') . ' = ' . (int) $existingId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$record = (object) [
|
||||
'post_id' => $postId,
|
||||
'service_id' => $serviceId,
|
||||
'service_type' => $serviceType,
|
||||
'posted_at' => $postedAt,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
'hour_of_day' => $hourOfDay,
|
||||
'impressions' => $impressions,
|
||||
'engagements' => $engagements,
|
||||
'clicks' => $clicks,
|
||||
'shares' => $shares,
|
||||
'engagement_rate' => $engagementRate,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitecross_analytics', $record);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data as a 7x24 grid of average engagement rates.
|
||||
*
|
||||
* @param string $serviceType Optional service type filter
|
||||
* @param int $days Number of days to look back (0 = all time)
|
||||
*
|
||||
* @return array 7x24 grid: [ day_of_week => [ hour_of_day => avg_engagement_rate ] ]
|
||||
*/
|
||||
public static function getHeatmapData(string $serviceType = '', int $days = 90): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('day_of_week'),
|
||||
$db->quoteName('hour_of_day'),
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate',
|
||||
'COUNT(*) AS post_count',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('day_of_week'))
|
||||
->group($db->quoteName('hour_of_day'))
|
||||
->order($db->quoteName('day_of_week') . ' ASC')
|
||||
->order($db->quoteName('hour_of_day') . ' ASC');
|
||||
|
||||
if ($serviceType !== '') {
|
||||
$query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType));
|
||||
}
|
||||
|
||||
if ($days > 0) {
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList();
|
||||
|
||||
// Build 7x24 grid initialised to zero
|
||||
$grid = [];
|
||||
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$grid[$d][$h] = ['avg_rate' => 0.00, 'post_count' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [
|
||||
'avg_rate' => round((float) $row->avg_rate, 2),
|
||||
'post_count' => (int) $row->post_count,
|
||||
];
|
||||
}
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best times to post ranked by average engagement rate.
|
||||
*
|
||||
* @param string $serviceType Optional service type filter
|
||||
* @param int $limit Number of results to return
|
||||
*
|
||||
* @return array List of [day_of_week, hour_of_day, avg_rate, post_count]
|
||||
*/
|
||||
public static function getBestTimes(string $serviceType = '', int $limit = 5): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('day_of_week'),
|
||||
$db->quoteName('hour_of_day'),
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate',
|
||||
'COUNT(*) AS post_count',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('day_of_week'))
|
||||
->group($db->quoteName('hour_of_day'))
|
||||
->having('COUNT(*) >= 1')
|
||||
->order('avg_rate DESC');
|
||||
|
||||
if ($serviceType !== '') {
|
||||
$query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType));
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
$dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$hour = (int) $row['hour_of_day'];
|
||||
$ampm = $hour < 12 ? 'AM' : 'PM';
|
||||
$hour12 = $hour % 12 ?: 12;
|
||||
|
||||
$results[] = [
|
||||
'day_of_week' => (int) $row['day_of_week'],
|
||||
'day_name' => $dayNames[(int) $row['day_of_week']],
|
||||
'hour_of_day' => $hour,
|
||||
'hour_label' => $hour12 . ':00 ' . $ampm,
|
||||
'avg_rate' => round((float) $row['avg_rate'], 2),
|
||||
'post_count' => (int) $row['post_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement stats grouped by service type.
|
||||
*
|
||||
* @param int $days Number of days to look back (0 = all time)
|
||||
*
|
||||
* @return array List of [service_type, total_posts, avg_engagement_rate, total_impressions, total_engagements]
|
||||
*/
|
||||
public static function getServiceBreakdown(int $days = 30): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('service_type'),
|
||||
'COUNT(*) AS total_posts',
|
||||
'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_engagement_rate',
|
||||
'SUM(' . $db->quoteName('impressions') . ') AS total_impressions',
|
||||
'SUM(' . $db->quoteName('engagements') . ') AS total_engagements',
|
||||
'SUM(' . $db->quoteName('clicks') . ') AS total_clicks',
|
||||
'SUM(' . $db->quoteName('shares') . ') AS total_shares',
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitecross_analytics'))
|
||||
->group($db->quoteName('service_type'))
|
||||
->order('avg_engagement_rate DESC');
|
||||
|
||||
if ($days > 0) {
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
$query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['avg_engagement_rate'] = round((float) $row['avg_engagement_rate'], 2);
|
||||
$row['total_posts'] = (int) $row['total_posts'];
|
||||
$row['total_impressions'] = (int) $row['total_impressions'];
|
||||
$row['total_engagements'] = (int) $row['total_engagements'];
|
||||
$row['total_clicks'] = (int) $row['total_clicks'];
|
||||
$row['total_shares'] = (int) $row['total_shares'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Shortens URLs via Bitly, Rebrandly, or YOURLS.
|
||||
*
|
||||
* Returns the original URL on any failure so cross-posts are never broken.
|
||||
*/
|
||||
class LinkShortenerHelper
|
||||
{
|
||||
/**
|
||||
* Shorten a URL using the configured provider.
|
||||
*
|
||||
* @param string $url The URL to shorten
|
||||
*
|
||||
* @return string Shortened URL, or the original on failure/disabled
|
||||
*/
|
||||
public static function shorten(string $url): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$provider = $params->get('link_shortener', 'none');
|
||||
|
||||
if ($provider === 'none' || empty($url)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$apiKey = $params->get('link_shortener_api_key', '');
|
||||
|
||||
switch ($provider) {
|
||||
case 'bitly':
|
||||
return self::shortenWithBitly($url, $apiKey);
|
||||
|
||||
case 'rebrandly':
|
||||
return self::shortenWithRebrandly($url, $apiKey);
|
||||
|
||||
case 'yourls':
|
||||
$apiUrl = $params->get('link_shortener_yourls_url', '');
|
||||
$token = $params->get('link_shortener_yourls_token', '');
|
||||
return self::shortenWithYourls($url, $apiUrl, $token);
|
||||
|
||||
default:
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via Bitly API v4.
|
||||
*/
|
||||
public static function shortenWithBitly(string $url, string $apiKey): string
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api-ssl.bitly.com/v4/shorten',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['long_url' => $url]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['link'] ?? $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via Rebrandly API.
|
||||
*/
|
||||
public static function shortenWithRebrandly(string $url, string $apiKey, string $workspace = ''): string
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'apikey: ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
if (!empty($workspace)) {
|
||||
$headers[] = 'workspace: ' . $workspace;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => 'https://api.rebrandly.com/v1/links',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['destination' => $url]),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$short = $data['shortUrl'] ?? '';
|
||||
|
||||
return !empty($short) ? 'https://' . ltrim($short, 'https://') : $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten via YOURLS API (self-hosted).
|
||||
*/
|
||||
public static function shortenWithYourls(string $url, string $apiUrl, string $signatureToken): string
|
||||
{
|
||||
if (empty($apiUrl) || empty($signatureToken)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$endpoint = rtrim($apiUrl, '/') . '?' . http_build_query([
|
||||
'action' => 'shorturl',
|
||||
'format' => 'json',
|
||||
'signature' => $signatureToken,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
return $data['shorturl'] ?? $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class 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 = '<img src="' . $imageUrl . '" alt="" style="width:100%;max-height:260px;object-fit:cover;border-radius:8px;margin:8px 0;">';
|
||||
}
|
||||
|
||||
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 <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #cfd9de;border-radius:16px;padding:12px 16px;background:#fff;">
|
||||
<div style="display:flex;align-items:center;margin-bottom:8px;">
|
||||
<div style="width:40px;height:40px;border-radius:50%;background:#1da1f2;margin-right:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px;">X</div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:15px;color:#0f1419;">{$author}</div>
|
||||
<div style="color:#536471;font-size:13px;">@username</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#0f1419;margin-bottom:8px;">{$displayText}</div>
|
||||
{$imageHtml}
|
||||
<div style="margin-top:8px;padding:10px 12px;border:1px solid #cfd9de;border-radius:12px;background:#f7f9f9;">
|
||||
<div style="font-size:13px;color:#536471;margin-bottom:2px;">yoursite.com</div>
|
||||
<div style="font-size:15px;font-weight:600;color:#0f1419;">{$title}</div>
|
||||
</div>
|
||||
<div style="color:#536471;font-size:13px;margin-top:8px;text-align:right;">{$charCount}/280</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function renderFacebook(string $title, string $text, string $url, string $imageHtml, string $author): string
|
||||
{
|
||||
$displayText = !empty($text) ? $text : $title;
|
||||
|
||||
return <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #dddfe2;border-radius:8px;background:#fff;overflow:hidden;">
|
||||
<div style="padding:12px 16px;">
|
||||
<div style="display:flex;align-items:center;margin-bottom:8px;">
|
||||
<div style="width:40px;height:40px;border-radius:50%;background:#1877f2;margin-right:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:18px;">f</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px;color:#050505;">{$author}</div>
|
||||
<div style="color:#65676b;font-size:13px;">Just now</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#050505;">{$displayText}</div>
|
||||
</div>
|
||||
{$imageHtml}
|
||||
<div style="padding:10px 16px;border-top:1px solid #dddfe2;background:#f0f2f5;">
|
||||
<div style="font-size:12px;color:#65676b;text-transform:uppercase;">yoursite.com</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#050505;margin-top:2px;">{$title}</div>
|
||||
</div>
|
||||
</div>
|
||||
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 <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #c0cdd9;border-radius:8px;padding:14px 16px;background:#fff;">
|
||||
<div style="display:flex;align-items:center;margin-bottom:10px;">
|
||||
<div style="width:46px;height:46px;border-radius:8px;background:#6364ff;margin-right:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:20px;">M</div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:15px;color:#1a1a2e;">{$author}</div>
|
||||
<div style="color:#606984;font-size:13px;">@user@mastodon.social</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;line-height:1.5;color:#1a1a2e;">{$displayText}</div>
|
||||
{$imageHtml}
|
||||
<div style="margin-top:8px;padding:10px 12px;border:1px solid #c0cdd9;border-radius:8px;background:#f2f5f7;">
|
||||
<div style="font-size:14px;font-weight:600;color:#1a1a2e;">{$title}</div>
|
||||
<div style="font-size:12px;color:#606984;margin-top:2px;">yoursite.com</div>
|
||||
</div>
|
||||
<div style="color:#606984;font-size:13px;margin-top:8px;text-align:right;">{$charCount}/500</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function renderLinkedIn(string $title, string $text, string $url, string $imageHtml, string $author): string
|
||||
{
|
||||
$displayText = !empty($text) ? $text : $title;
|
||||
|
||||
return <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #e0dfdc;border-radius:8px;background:#fff;overflow:hidden;">
|
||||
<div style="padding:12px 16px;">
|
||||
<div style="display:flex;align-items:center;margin-bottom:8px;">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:#0a66c2;margin-right:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:18px;">in</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:14px;color:#191919;">{$author}</div>
|
||||
<div style="color:#666;font-size:12px;">Just now</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:14px;line-height:1.4;color:#191919;">{$displayText}</div>
|
||||
</div>
|
||||
{$imageHtml}
|
||||
<div style="padding:8px 16px 12px;border-top:1px solid #e0dfdc;background:#f9fafb;">
|
||||
<div style="font-size:14px;font-weight:600;color:#191919;">{$title}</div>
|
||||
<div style="font-size:12px;color:#666;margin-top:2px;">yoursite.com</div>
|
||||
</div>
|
||||
</div>
|
||||
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 <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #d1d5db;border-radius:12px;padding:12px 16px;background:#fff;">
|
||||
<div style="display:flex;align-items:center;margin-bottom:8px;">
|
||||
<div style="width:42px;height:42px;border-radius:50%;background:#0085ff;margin-right:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px;">B</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px;color:#1e2937;">{$author}</div>
|
||||
<div style="color:#6b7280;font-size:13px;">@user.bsky.social</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#1e2937;">{$displayText}</div>
|
||||
{$imageHtml}
|
||||
<div style="margin-top:8px;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;background:#f9fafb;">
|
||||
<div style="font-size:14px;font-weight:600;color:#1e2937;">{$title}</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-top:2px;">yoursite.com</div>
|
||||
</div>
|
||||
<div style="color:#6b7280;font-size:13px;margin-top:8px;text-align:right;">{$charCount}/300</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function renderTelegram(string $title, string $text, string $url, string $imageHtml): string
|
||||
{
|
||||
$displayText = !empty($text) ? $text : $title;
|
||||
|
||||
return <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;background:#effdde;border-radius:12px;padding:10px 14px;margin-left:60px;">
|
||||
{$imageHtml}
|
||||
<div style="font-size:15px;line-height:1.4;color:#000;">{$displayText}</div>
|
||||
<div style="margin-top:8px;padding:8px 12px;border-left:3px solid #4fae4e;background:#fff;border-radius:0 8px 8px 0;">
|
||||
<div style="font-size:14px;font-weight:600;color:#000;">{$title}</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:2px;">yoursite.com</div>
|
||||
</div>
|
||||
<div style="color:#5fb452;font-size:11px;text-align:right;margin-top:4px;">Just now</div>
|
||||
</div>
|
||||
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 <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:500px;border:1px solid #ddd;border-radius:8px;padding:12px 16px;background:#fff;">
|
||||
<div style="font-weight:600;font-size:13px;color:#666;margin-bottom:8px;text-transform:uppercase;">{$platformLabel}</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#333;">{$displayText}</div>
|
||||
{$imageHtml}
|
||||
<div style="margin-top:8px;padding:8px 12px;border:1px solid #ddd;border-radius:6px;background:#f9f9f9;">
|
||||
<div style="font-size:14px;font-weight:600;color:#333;">{$title}</div>
|
||||
<div style="font-size:12px;color:#999;">yoursite.com</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class SocialImageHelper
|
||||
{
|
||||
private const WIDTH = 1200;
|
||||
private const HEIGHT = 630;
|
||||
private const PADDING = 60;
|
||||
private const MAX_TITLE_CHARS_PER_LINE = 30;
|
||||
|
||||
public static function generate(string $title, string $siteName, ?string $backgroundPath = null, array $options = []): string
|
||||
{
|
||||
if (!\function_exists('imagecreatetruecolor')) {
|
||||
throw new \RuntimeException('PHP GD extension is required for social image generation.');
|
||||
}
|
||||
|
||||
$bgColor = $options['bg_color'] ?? '#1a1a2e';
|
||||
$textColor = $options['text_color'] ?? '#ffffff';
|
||||
$overlayType = $options['overlay'] ?? 'dark';
|
||||
$fontSize = (int) ($options['font_size'] ?? 36);
|
||||
|
||||
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
|
||||
if ($image === false) {
|
||||
throw new \RuntimeException('Failed to create image canvas.');
|
||||
}
|
||||
|
||||
$bgRgb = self::hexToRgb($bgColor);
|
||||
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
|
||||
imagefill($image, 0, 0, $bg);
|
||||
|
||||
if ($backgroundPath !== null && is_file($backgroundPath)) {
|
||||
self::drawBackground($image, $backgroundPath);
|
||||
}
|
||||
|
||||
if ($overlayType !== 'none') {
|
||||
self::drawOverlay($image, $overlayType);
|
||||
}
|
||||
|
||||
$textRgb = self::hexToRgb($textColor);
|
||||
$textCol = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
|
||||
|
||||
$fontPath = self::findFont();
|
||||
|
||||
if ($fontPath !== null) {
|
||||
self::drawTextTtf($image, $title, $siteName, $fontPath, $fontSize, $textCol);
|
||||
} else {
|
||||
self::drawTextFallback($image, $title, $siteName, $textCol);
|
||||
}
|
||||
|
||||
$tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp');
|
||||
$outPath = $tmpDir . '/social_' . md5($title . $siteName . microtime()) . '.png';
|
||||
|
||||
imagepng($image, $outPath, 6);
|
||||
imagedestroy($image);
|
||||
|
||||
return $outPath;
|
||||
}
|
||||
|
||||
private static function drawBackground(\GdImage $image, string $path): void
|
||||
{
|
||||
$info = @getimagesize($path);
|
||||
|
||||
if ($info === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = match ($info[2]) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||
IMAGETYPE_WEBP => \function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if ($source === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$srcW = imagesx($source);
|
||||
$srcH = imagesy($source);
|
||||
|
||||
$scale = max(self::WIDTH / $srcW, self::HEIGHT / $srcH);
|
||||
$newW = (int) ($srcW * $scale);
|
||||
$newH = (int) ($srcH * $scale);
|
||||
$dstX = (int) ((self::WIDTH - $newW) / 2);
|
||||
$dstY = (int) ((self::HEIGHT - $newH) / 2);
|
||||
|
||||
imagecopyresampled($image, $source, $dstX, $dstY, 0, 0, $newW, $newH, $srcW, $srcH);
|
||||
imagedestroy($source);
|
||||
}
|
||||
|
||||
private static function drawOverlay(\GdImage $image, string $type): void
|
||||
{
|
||||
$opacity = ($type === 'light') ? 80 : 160;
|
||||
$color = imagecolorallocatealpha($image, 0, 0, 0, 127 - (int) ($opacity * 127 / 255));
|
||||
|
||||
if ($color === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
imagesavealpha($image, true);
|
||||
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $color);
|
||||
}
|
||||
|
||||
private static function drawTextTtf(\GdImage $image, string $title, string $siteName, string $fontPath, int $fontSize, int $color): void
|
||||
{
|
||||
$lines = self::wordWrap($title, $fontPath, $fontSize, self::WIDTH - self::PADDING * 2);
|
||||
$lineH = (int) ($fontSize * 1.4);
|
||||
$totalH = \count($lines) * $lineH;
|
||||
$startY = (int) ((self::HEIGHT - $totalH) / 2) + $fontSize;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$box = imagettfbbox($fontSize, 0, $fontPath, $line);
|
||||
$lineW = abs($box[2] - $box[0]);
|
||||
$x = (int) ((self::WIDTH - $lineW) / 2);
|
||||
imagettftext($image, $fontSize, 0, $x, $startY + $i * $lineH, $color, $fontPath, $line);
|
||||
}
|
||||
|
||||
$siteSize = (int) ($fontSize * 0.5);
|
||||
$box = imagettfbbox($siteSize, 0, $fontPath, $siteName);
|
||||
$siteW = abs($box[2] - $box[0]);
|
||||
$siteX = (int) ((self::WIDTH - $siteW) / 2);
|
||||
$siteY = self::HEIGHT - self::PADDING;
|
||||
|
||||
$siteCol = imagecolorallocatealpha($image, 255, 255, 255, 40);
|
||||
|
||||
if ($siteCol !== false) {
|
||||
imagettftext($image, $siteSize, 0, $siteX, $siteY, $siteCol, $fontPath, $siteName);
|
||||
}
|
||||
}
|
||||
|
||||
private static function drawTextFallback(\GdImage $image, string $title, string $siteName, int $color): void
|
||||
{
|
||||
$maxChars = (int) (self::WIDTH / 10);
|
||||
$wrapped = wordwrap($title, $maxChars, "\n", true);
|
||||
$lines = explode("\n", $wrapped);
|
||||
$lineH = 20;
|
||||
$totalH = \count($lines) * $lineH;
|
||||
$startY = (int) ((self::HEIGHT - $totalH) / 2);
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$lineW = \strlen($line) * 9;
|
||||
$x = max(self::PADDING, (int) ((self::WIDTH - $lineW) / 2));
|
||||
imagestring($image, 5, $x, $startY + $i * $lineH, $line, $color);
|
||||
}
|
||||
|
||||
$siteW = \strlen($siteName) * 7;
|
||||
$siteX = max(self::PADDING, (int) ((self::WIDTH - $siteW) / 2));
|
||||
$siteY = self::HEIGHT - 40;
|
||||
|
||||
$gray = imagecolorallocate($image, 180, 180, 180);
|
||||
|
||||
if ($gray !== false) {
|
||||
imagestring($image, 3, $siteX, $siteY, $siteName, $gray);
|
||||
}
|
||||
}
|
||||
|
||||
private static function wordWrap(string $text, string $fontPath, int $fontSize, int $maxWidth): array
|
||||
{
|
||||
$words = explode(' ', $text);
|
||||
$lines = [];
|
||||
$line = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$test = ($line === '') ? $word : $line . ' ' . $word;
|
||||
$box = imagettfbbox($fontSize, 0, $fontPath, $test);
|
||||
$testW = abs($box[2] - $box[0]);
|
||||
|
||||
if ($testW > $maxWidth && $line !== '') {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
} else {
|
||||
$line = $test;
|
||||
}
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return $lines ?: [$text];
|
||||
}
|
||||
|
||||
private static function findFont(): ?string
|
||||
{
|
||||
$candidates = [
|
||||
JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||
'C:/Windows/Fonts/arialbd.ttf',
|
||||
];
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,175 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Analytics: Best Times to Post Heatmap -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h5>
|
||||
<select id="heatmapServiceFilter" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value=""><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS'); ?></option>
|
||||
<?php
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$stQuery = $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($stQuery);
|
||||
$serviceTypes = $db->loadColumn();
|
||||
foreach ($serviceTypes as $st) :
|
||||
?>
|
||||
<option value="<?php echo htmlspecialchars($st); ?>"><?php echo htmlspecialchars(ucfirst($st)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="heatmapContainer">
|
||||
<p class="text-muted" id="heatmapLoading"><?php echo Text::_('JLIB_HTML_BEHAVIOR_LOADING'); ?></p>
|
||||
<div id="heatmapNoData" style="display:none;">
|
||||
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?></p>
|
||||
</div>
|
||||
<div id="heatmapGrid" style="display:none;">
|
||||
<style>
|
||||
.msc-heatmap { border-collapse: collapse; width: 100%; font-size: 11px; }
|
||||
.msc-heatmap th, .msc-heatmap td { text-align: center; padding: 3px 2px; min-width: 28px; }
|
||||
.msc-heatmap th { font-weight: 600; color: #666; font-size: 10px; }
|
||||
.msc-heatmap td.msc-hm-cell { border-radius: 3px; cursor: default; position: relative; }
|
||||
.msc-heatmap td.msc-hm-cell:hover { outline: 2px solid #333; z-index: 1; }
|
||||
.msc-heatmap .msc-hm-day { text-align: right; padding-right: 8px; font-weight: 600; color: #555; white-space: nowrap; }
|
||||
</style>
|
||||
<table class="msc-heatmap" id="heatmapTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<?php for ($h = 0; $h < 24; $h++) :
|
||||
$label = $h % 12 ?: 12;
|
||||
$suffix = $h < 12 ? 'a' : 'p';
|
||||
?>
|
||||
<th><?php echo $label . $suffix; ?></th>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
for ($d = 0; $d < 7; $d++) :
|
||||
?>
|
||||
<tr>
|
||||
<td class="msc-hm-day"><?php echo $dayLabels[$d]; ?></td>
|
||||
<?php for ($h = 0; $h < 24; $h++) : ?>
|
||||
<td class="msc-hm-cell" id="hm-<?php echo $d; ?>-<?php echo $h; ?>" title="<?php echo $dayLabels[$d] . ' ' . ($h % 12 ?: 12) . ':00 ' . ($h < 12 ? 'AM' : 'PM'); ?>"></td>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex align-items-center justify-content-end mt-2" style="font-size:11px;color:#666;">
|
||||
<span class="me-1"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE'); ?>:</span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#ebedf0;border-radius:2px;margin:0 1px;" title="0%"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#9be9a8;border-radius:2px;margin:0 1px;" title="Low"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#40c463;border-radius:2px;margin:0 1px;" title="Medium"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#30a14e;border-radius:2px;margin:0 1px;" title="High"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#216e39;border-radius:2px;margin:0 1px;" title="Very High"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="heatmapBestTimes" style="display:none;" class="mt-3 pt-3 border-top">
|
||||
<strong><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?>:</strong>
|
||||
<ul id="bestTimesList" class="mb-0 mt-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo \Joomla\CMS\Session\Session::getFormToken(); ?>';
|
||||
|
||||
function loadHeatmap(serviceType) {
|
||||
var url = 'index.php?option=com_mokosuitecross&task=analytics.heatmap&format=json'
|
||||
+ '&service_type=' + encodeURIComponent(serviceType || '')
|
||||
+ '&days=90&' + token + '=1';
|
||||
|
||||
document.getElementById('heatmapLoading').style.display = '';
|
||||
document.getElementById('heatmapGrid').style.display = 'none';
|
||||
document.getElementById('heatmapNoData').style.display = 'none';
|
||||
document.getElementById('heatmapBestTimes').style.display = 'none';
|
||||
|
||||
fetch(url)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('heatmapLoading').style.display = 'none';
|
||||
|
||||
if (!data.success) {
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var grid = data.grid;
|
||||
var maxRate = 0;
|
||||
var hasData = false;
|
||||
|
||||
for (var d = 0; d < 7; d++) {
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var rate = grid[d] && grid[d][h] ? parseFloat(grid[d][h].avg_rate) : 0;
|
||||
if (rate > maxRate) maxRate = rate;
|
||||
if (rate > 0) hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('heatmapGrid').style.display = '';
|
||||
|
||||
var colors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
||||
for (var d = 0; d < 7; d++) {
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var cell = document.getElementById('hm-' + d + '-' + h);
|
||||
if (!cell) continue;
|
||||
var val = grid[d] && grid[d][h] ? grid[d][h] : {avg_rate: 0, post_count: 0};
|
||||
var rate = parseFloat(val.avg_rate);
|
||||
var count = parseInt(val.post_count, 10);
|
||||
var level = 0;
|
||||
if (maxRate > 0 && rate > 0) {
|
||||
var pct = rate / maxRate;
|
||||
if (pct <= 0.25) level = 1;
|
||||
else if (pct <= 0.50) level = 2;
|
||||
else if (pct <= 0.75) level = 3;
|
||||
else level = 4;
|
||||
}
|
||||
cell.style.backgroundColor = colors[level];
|
||||
cell.title = cell.title.split(' - ')[0] + ' - ' + rate.toFixed(1) + '% (' + count + ' posts)';
|
||||
}
|
||||
}
|
||||
|
||||
// Show best times
|
||||
if (data.best_times && data.best_times.length > 0) {
|
||||
document.getElementById('heatmapBestTimes').style.display = '';
|
||||
var list = document.getElementById('bestTimesList');
|
||||
list.innerHTML = '';
|
||||
var top = data.best_times.slice(0, 3);
|
||||
for (var i = 0; i < top.length; i++) {
|
||||
var bt = top[i];
|
||||
var li = document.createElement('li');
|
||||
li.textContent = bt.day_name + ' at ' + bt.hour_label + ' (' + bt.avg_rate.toFixed(1) + '% avg engagement)';
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('heatmapLoading').style.display = 'none';
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('heatmapServiceFilter').addEventListener('change', function() {
|
||||
loadHeatmap(this.value);
|
||||
});
|
||||
|
||||
loadHeatmap('');
|
||||
});
|
||||
</script>
|
||||
<!-- Recent Activity -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteCross</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+101
-43
@@ -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 = '<div class="mb-3">'
|
||||
. '<button type="button" id="mokosuitecross-ai-btn" class="btn btn-sm btn-outline-info" onclick="mokosuitecrossAiGenerate()">'
|
||||
. '<span class="icon-magic" aria-hidden="true"></span> '
|
||||
. \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATE')
|
||||
. '</button>'
|
||||
. '<span id="mokosuitecross-ai-status" class="ms-2 small"></span>'
|
||||
. '</div>'
|
||||
. '<script>'
|
||||
. 'function mokosuitecrossAiGenerate(){'
|
||||
. 'var btn=document.getElementById("mokosuitecross-ai-btn");'
|
||||
. 'var st=document.getElementById("mokosuitecross-ai-status");'
|
||||
. 'btn.disabled=true;st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATING', true) . '";'
|
||||
. 'fetch("' . $aiUrl . '")'
|
||||
. '.then(function(r){return r.json();})'
|
||||
. '.then(function(d){'
|
||||
. 'btn.disabled=false;'
|
||||
. 'if(!d.success){st.textContent=d.error||"Error";return;}'
|
||||
. 'st.textContent="' . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_AI_GENERATED', true) . '";'
|
||||
. 'var f=d.data;'
|
||||
. 'var s=document.getElementById("jform_attribs_mokosuitecross_social_text");if(s)s.value=f.social;'
|
||||
. 'var h=document.getElementById("jform_attribs_mokosuitecross_short_text");if(h)h.value=f.short;'
|
||||
. 'var c=document.getElementById("jform_attribs_mokosuitecross_chat_text");if(c)c.value=f.chat;'
|
||||
. 'var e=document.getElementById("jform_attribs_mokosuitecross_email_subject");if(e)e.value=f.email_subject;'
|
||||
. '})'
|
||||
. '.catch(function(){btn.disabled=false;st.textContent="Request failed";});'
|
||||
. '}'
|
||||
. '</script>';
|
||||
|
||||
$aiXml = '<?xml version="1.0"?>
|
||||
<form><fields name="attribs"><fieldset name="mokosuitecross_share">
|
||||
<field name="mokosuitecross_ai_generate" type="note"
|
||||
label="" description="" />
|
||||
</fieldset></fields></form>';
|
||||
$form->load($aiXml);
|
||||
$form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs');
|
||||
}
|
||||
|
||||
// Cross-post history panel for existing articles
|
||||
|
||||
if ($articleId > 0) {
|
||||
$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 = '<div id="mokosuitecross-preview-panel">'
|
||||
. '<div class="mb-2">'
|
||||
. '<select id="mokosuitecross-preview-platform" class="form-select form-select-sm" style="width:auto;display:inline-block;">'
|
||||
. '<option value="all">All Platforms</option>'
|
||||
. '<option value="twitter">X / Twitter</option>'
|
||||
. '<option value="facebook">Facebook</option>'
|
||||
. '<option value="linkedin">LinkedIn</option>'
|
||||
. '<option value="mastodon">Mastodon</option>'
|
||||
. '<option value="bluesky">Bluesky</option>'
|
||||
. '<option value="telegram">Telegram</option>'
|
||||
. '</select> '
|
||||
. '<button type="button" class="btn btn-sm btn-outline-primary" onclick="mokosuitecrossLoadPreview()">'
|
||||
. '<span class="icon-eye" aria-hidden="true"></span> Preview</button>'
|
||||
. '</div>'
|
||||
. '<div id="mokosuitecross-preview-output" style="max-height:600px;overflow-y:auto;"></div>'
|
||||
. '</div>'
|
||||
. '<script>'
|
||||
. 'function mokosuitecrossLoadPreview(){'
|
||||
. 'var p=document.getElementById("mokosuitecross-preview-platform").value;'
|
||||
. 'var o=document.getElementById("mokosuitecross-preview-output");'
|
||||
. 'o.innerHTML="<div class=\"text-center p-3\"><span class=\"spinner-border spinner-border-sm\"></span> Loading...</div>";'
|
||||
. 'fetch("' . $previewUrl . '&platform="+p)'
|
||||
. '.then(function(r){return r.text();})'
|
||||
. '.then(function(h){o.innerHTML=h;})'
|
||||
. '.catch(function(){o.innerHTML="<div class=\"alert alert-danger\">Preview failed</div>";});'
|
||||
. '}'
|
||||
. '</script>';
|
||||
|
||||
$previewXml = '<?xml version="1.0"?>
|
||||
<form><fields name="attribs"><fieldset name="mokosuitecross_preview" label="PLG_CONTENT_MOKOSUITECROSS_FIELDSET_PREVIEW">
|
||||
<field name="mokosuitecross_preview_panel" type="note"
|
||||
label="PLG_CONTENT_MOKOSUITECROSS_PREVIEW"
|
||||
description="" />
|
||||
</fieldset></fields></form>';
|
||||
$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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - ActivityPub (Fediverse)</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Google Blogger</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Bluesky</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokosuitecross" method="upgrade">
|
||||
<name>MokoSuiteCross - Brevo (Sendinblue)</name>
|
||||
<version>01.07.00</version>
|
||||
<version>01.08.54</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user