Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5461bccd49 | |||
| 167ae2dc06 | |||
| 20250db9e0 | |||
| cc70e4abc2 | |||
| c56f3473b1 | |||
| ddb25fa99f | |||
| c8a3c58495 | |||
| 7ef9a23ef8 | |||
| d3e73a0ea4 | |||
| e26d0ed400 | |||
| f73e536d05 | |||
| 4894a703c2 | |||
| 07a4fcc0b5 | |||
| 4b37489c41 | |||
| c415d4813f | |||
| 72cde2c8ca | |||
| 4628291217 | |||
| cf2035abbe | |||
| 9bffe09a1f | |||
| b7c0e70e44 | |||
| f66228872b | |||
| 3b324feda0 | |||
| e55d8d0dea | |||
| 5a9bf8257b | |||
| 8db35e5d30 | |||
| 2821d07208 | |||
| fd578d543d | |||
| 331eca3b49 | |||
| 0cdbc677c5 | |||
| 716446c9a4 | |||
| 221697b0c7 | |||
| cc7baa540d | |||
| efd1d2bcd3 | |||
| 9581355985 | |||
| 891cf049f5 | |||
| 183fcdb8bb | |||
| 15550b975c | |||
| d0cc4c9505 | |||
| 36e771963c | |||
| 73bdc3c456 | |||
| 0d1473e433 | |||
| e2adb9d964 | |||
| 68fe793008 | |||
| b98878880d | |||
| 0acd87ebf8 | |||
| 02d72c8f34 | |||
| 77a401d606 | |||
| e8080a22d4 | |||
| 12ec46e5a6 | |||
| d7b5f4ee8e | |||
| fd8a6c1380 | |||
| 26e145afe4 | |||
| e38de4578d | |||
| fc4e421bed | |||
| 8850cff9c2 | |||
| ebcfbfd249 | |||
| cfa66189f7 | |||
| 8af7f37dce | |||
| aa2ed41dce | |||
| 293944a162 | |||
| 0276b766dd | |||
| 57b601ea24 | |||
| d87ce687e3 | |||
| e627aa885b | |||
| 4144489ecb | |||
| 78f57f4f69 | |||
| 6d7838a817 | |||
| a8ca52bcc8 | |||
| 979fdf9cf0 | |||
| ad4b70e8ea | |||
| 201c94b97f | |||
| f6df1b344d | |||
| a2726ad6b9 | |||
| c20df6c97d | |||
| 161c191e78 | |||
| d5afe0b738 | |||
| eb106e3c60 | |||
| 932858b2b2 | |||
| bdd3426dcc | |||
| 92c49fa0c3 | |||
| e46497eca5 | |||
| 08f73ac7ae | |||
| 15d55ea7a5 | |||
| a8bc86f974 | |||
| b75fdeb11b | |||
| cd9d1e4603 | |||
| 1467c093ca | |||
| f666c8915c | |||
| 5b75cb836b | |||
| 1182e98673 | |||
| a3421cc220 | |||
| 5d1e2bb884 | |||
| fb74521468 | |||
| e39143646c | |||
| fd44c4a967 | |||
| bb8f85c407 | |||
| 569871e105 | |||
| c3ad06f533 | |||
| 3dcc9fb77c | |||
| ba9713230f | |||
| 0afbcf9310 | |||
| 7e25c44122 | |||
| 3446b87409 | |||
| ff13f2077e | |||
| d4f18570fd | |||
| 006bb952fe | |||
| 7b7301c929 | |||
| a972dcc7c5 | |||
| 58d3c1bdf4 | |||
| a1fd153542 | |||
| 8f28f731a7 | |||
| d61747759e | |||
| 663bc0293c | |||
| 0e7dea05bc | |||
| 8329e4a965 | |||
| c60c784665 | |||
| 633baa550a | |||
| d07cf877fb | |||
| 05ec3373d2 | |||
| 76fca03d4e | |||
| 55d8ff2b67 |
+2
-1
@@ -113,7 +113,8 @@ releases/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
/site/
|
||||
site/
|
||||
!source/packages/*/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# MokoJoomStoreLocator
|
||||
|
||||
Store locator listing component with coordinating map and search modules for Joomla.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokojoomstorelocator` |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [MokoJoomStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/wiki) |
|
||||
|
||||
## Package Contents
|
||||
|
||||
| Extension | Type | Element |
|
||||
|---|---|---|
|
||||
| Store Locator Component | component | `com_mokojoomstorelocator` |
|
||||
| Store Locator Map | module (site) | `mod_mokojoomstorelocator_map` |
|
||||
| Store Locator Search | module (site) | `mod_mokojoomstorelocator_search` |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make build # Build package ZIP containing all sub-extensions
|
||||
make lint # Run PHP linter
|
||||
make validate # Lint + validation checks
|
||||
make release # Validate + build
|
||||
make clean # Clean build artifacts
|
||||
composer install # Install PHP dev dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Joomla **package** with component + 2 modules:
|
||||
|
||||
- `src/pkg_mokojoomstorelocator.xml` — package manifest
|
||||
- `src/script.php` — package install/upgrade/uninstall script
|
||||
- `src/packages/com_mokojoomstorelocator/` — main component
|
||||
- `admin/` — admin MVC (controllers, models, views, forms, tables, SQL)
|
||||
- `site/` — frontend MVC (controllers, models, views, templates)
|
||||
- `src/packages/mod_mokojoomstorelocator_map/` — map display module
|
||||
- `src/packages/mod_mokojoomstorelocator_search/` — search/filter module
|
||||
### Database Table
|
||||
|
||||
`#__mokojoomstorelocator_locations` — location data (coordinates, address, contact, business hours)
|
||||
|
||||
Namespace: `Moko\Component\MokoJoomStoreLocator`
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla table operations: `bind() → check() → store()`, never `save()`
|
||||
- SPDX license headers on all PHP files
|
||||
@@ -0,0 +1,251 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/branch-protection.yml
|
||||
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BRANCH PROTECTION SETUP |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||
# | |
|
||||
# | main — Require PR, block rejected reviews, no force push |
|
||||
# | dev — Allow push, no force push, no delete |
|
||||
# | rc — Allow push, no force push, no delete |
|
||||
# | beta — Allow push, no force push, no delete |
|
||||
# | alpha — Allow push, no force push, no delete |
|
||||
# | |
|
||||
# | jmiller has override authority on all branches. |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Branch Protection Setup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Preview mode (no changes)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
protect:
|
||||
name: Apply Branch Protection Rules
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
# Platform/standards/infra repos to exclude
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
# User-specified repos
|
||||
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||
else
|
||||
# Fetch all org repos
|
||||
PAGE=1
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter out excluded repos
|
||||
FILTERED=""
|
||||
for REPO in $REPOS; do
|
||||
SKIP=false
|
||||
for EX in $EXCLUDE; do
|
||||
if [ "$REPO" = "$EX" ]; then
|
||||
SKIP=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$SKIP" = "false" ]; then
|
||||
FILTERED="$FILTERED $REPO"
|
||||
fi
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$REPOS" | wc -w)
|
||||
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||
|
||||
- name: Apply protection rules
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
REPOS="${{ steps.repos.outputs.repos }}"
|
||||
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# ── Rule definitions ──────────────────────────────────────
|
||||
# Only the CI bot (jmiller token) can push directly.
|
||||
# All human contributors must use PRs.
|
||||
# Force push disabled on all branches.
|
||||
|
||||
RULE_MAIN='{
|
||||
"rule_name": "main",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": true,
|
||||
"block_on_rejected_reviews": true,
|
||||
"block_on_outdated_branch": false,
|
||||
"priority": 1
|
||||
}'
|
||||
|
||||
RULE_DEV='{
|
||||
"rule_name": "dev",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 2
|
||||
}'
|
||||
|
||||
RULE_RC='{
|
||||
"rule_name": "rc",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 3
|
||||
}'
|
||||
|
||||
RULE_BETA='{
|
||||
"rule_name": "beta",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 4
|
||||
}'
|
||||
|
||||
RULE_ALPHA='{
|
||||
"rule_name": "alpha",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 5
|
||||
}'
|
||||
|
||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||
|
||||
# ── Apply rules to each repo ──────────────────────────────
|
||||
for REPO in $REPOS; do
|
||||
echo ""
|
||||
echo "═══ ${REPO} ═══"
|
||||
|
||||
for i in "${!RULES[@]}"; do
|
||||
RULE="${RULES[$i]}"
|
||||
NAME="${RULE_NAMES[$i]}"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete existing rule if present (idempotent recreate)
|
||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||
curl -sS -o /dev/null -w "" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||
|
||||
# Create rule
|
||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RULE" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||
|
||||
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP" = "201" ]; then
|
||||
echo " ✅ ${NAME}"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Success: ${SUCCESS}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /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
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
@@ -1,324 +1,466 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +=======================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +=======================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +=======================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "RC release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: "Detect platform"
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||
# Feature/dev branches: bump minor for the new stable release
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
case "$HEAD_REF" in
|
||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||
*) BUMP="minor" ;;
|
||||
esac
|
||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
BUMP_FLAG=""
|
||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||
fi
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: "Read published version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||
VERSION=$(python3 -c "
|
||||
import json, sys, re
|
||||
r = json.load(sys.stdin)
|
||||
name = r.get('name', '')
|
||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||
print(m.group(1) if m else '')
|
||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
|
||||
# Update release body via API
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
content = open('CHANGELOG.md').read()
|
||||
old = '## [Unreleased]'
|
||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||
content = content.replace(old, new, 1)
|
||||
open('CHANGELOG.md', 'w').write(content)
|
||||
" "$VERSION" "$DATE"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||
git push origin main || true
|
||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete merged branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref != 'dev' &&
|
||||
github.event.pull_request.head.ref != 'main'
|
||||
|
||||
steps:
|
||||
- name: Delete source branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.gitea/workflows/ci-generic.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Lint & Validate ───────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint & Validate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect toolchain
|
||||
id: detect
|
||||
run: |
|
||||
HAS_PHP=false
|
||||
HAS_NODE=false
|
||||
[ -f "composer.json" ] && HAS_PHP=true
|
||||
[ -f "package.json" ] && HAS_NODE=true
|
||||
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
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 >/dev/null 2>&1
|
||||
fi
|
||||
php -v
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install PHP dependencies
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "package.json" ]; then
|
||||
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
echo "::error file=${file}::PHP syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
|
||||
|
||||
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -eq 0 ]; then
|
||||
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: TypeScript/JavaScript lint
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "node_modules/.bin/eslint" ]; then
|
||||
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
|
||||
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
|
||||
echo "::warning::ESLint config found but eslint not installed"
|
||||
else
|
||||
echo "No ESLint configured — skipping"
|
||||
fi
|
||||
|
||||
- name: TypeScript compile check
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
|
||||
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
|
||||
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
|
||||
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: PHPStan static analysis
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
|
||||
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect toolchain
|
||||
id: detect
|
||||
run: |
|
||||
HAS_PHP=false
|
||||
HAS_NODE=false
|
||||
[ -f "composer.json" ] && HAS_PHP=true
|
||||
[ -f "package.json" ] && HAS_NODE=true
|
||||
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
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 >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
|
||||
|
||||
- name: Run PHP tests
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "vendor/bin/phpunit" ]; then
|
||||
vendor/bin/phpunit --testdox 2>&1
|
||||
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||
echo "::warning::PHPUnit config found but phpunit not installed"
|
||||
else
|
||||
echo "No PHPUnit configured — skipping"
|
||||
fi
|
||||
|
||||
- name: Run Node.js tests
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
|
||||
npm test 2>&1
|
||||
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No test script in package.json — skipping"
|
||||
fi
|
||||
|
||||
- name: Build check
|
||||
run: |
|
||||
if [ -f "Makefile" ]; then
|
||||
make build 2>&1 || echo "::warning::Build failed or not configured"
|
||||
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
|
||||
npm run build 2>&1 || echo "::warning::Build failed"
|
||||
fi
|
||||
@@ -35,25 +35,32 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
php -v && composer --version
|
||||
|
||||
- name: Clone MokoStandards
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api
|
||||
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||
echo "mokocli already available on runner — skipping clone"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -124,8 +131,8 @@ jobs:
|
||||
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check required tags: name, version, author, namespace (Joomla 5+)
|
||||
for TAG in name version author namespace; do
|
||||
# Check required tags: name, version, author
|
||||
for TAG in name version author; do
|
||||
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
|
||||
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -133,6 +140,19 @@ jobs:
|
||||
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
done
|
||||
|
||||
# Namespace is required for components/plugins but not packages
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
|
||||
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
@@ -144,6 +164,75 @@ jobs:
|
||||
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Update server & packaging checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Update Server & Packaging" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
# Find the extension manifest
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
|
||||
# 1. Check <updateservers> exists and uses MokoGitea update server
|
||||
if ! grep -q '<updateservers>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<updateservers>\` tag — extension will not receive OTA updates"
|
||||
echo "- **Missing** \`<updateservers>\` — extension will not receive OTA updates" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
SERVER_URL=$(grep -oP '<server[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$SERVER_URL" ]; then
|
||||
echo "::warning file=${MANIFEST}::\`<updateservers>\` is empty — no server URL defined"
|
||||
echo "- **Empty** \`<updateservers>\` — no server URL defined" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
elif ! echo "$SERVER_URL" | grep -q 'git\.mokoconsulting\.tech'; then
|
||||
echo "::warning file=${MANIFEST}::Update server does not use MokoGitea engine: ${SERVER_URL}"
|
||||
echo "- **Non-MokoGitea update server:** \`${SERVER_URL}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo " Expected: \`https://git.mokoconsulting.tech/{org}/{repo}/updates.xml\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<updateservers>\`: MokoGitea engine ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Check <dlid> tag exists
|
||||
if ! grep -q '<dlid' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<dlid>\` tag — download ID authentication is not configured"
|
||||
echo "- **Missing** \`<dlid>\` — download ID authentication not configured" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<dlid>\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# 3. For packages: check <childuninstall> tag
|
||||
if [ "$EXT_TYPE" = "package" ]; then
|
||||
if ! grep -q '<childuninstall>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Package is missing \`<childuninstall>\` — child extensions will not be removed on uninstall"
|
||||
echo "- **Missing** \`<childuninstall>\` — child extensions will remain when package is uninstalled" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<childuninstall>\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} packaging warning(s).** These won't block CI but should be addressed." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Update server & packaging checks passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check language files referenced in manifest
|
||||
run: |
|
||||
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -225,14 +314,679 @@ jobs:
|
||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check config.xml and access.xml for components
|
||||
run: |
|
||||
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find all component manifests (XML with type="component")
|
||||
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$COMP_MANIFESTS" ]; then
|
||||
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $COMP_MANIFESTS; do
|
||||
COMP_DIR=$(dirname "$MANIFEST")
|
||||
COMP_NAME=$(basename "$COMP_DIR")
|
||||
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check access.xml exists
|
||||
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
for ACTION in core.admin core.manage; do
|
||||
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check config.xml exists
|
||||
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$CONFIG_FILE" ]; then
|
||||
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SQL schema validation
|
||||
run: |
|
||||
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find SQL files in source/htdocs
|
||||
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$SQL_FILES" ]; then
|
||||
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $SQL_FILES; do
|
||||
# Basic syntax check: balanced parentheses, no empty files
|
||||
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||
if [ "$SIZE" -eq 0 ]; then
|
||||
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for common SQL errors
|
||||
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
|
||||
# Check update SQL files follow version numbering pattern
|
||||
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -n "$UPDATE_DIR" ]; then
|
||||
BAD_NAMES=0
|
||||
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||
[ ! -f "$UFILE" ] && continue
|
||||
BASENAME=$(basename "$UFILE" .sql)
|
||||
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||
BAD_NAMES=$((BAD_NAMES + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||
ERRORS=$((ERRORS + BAD_NAMES))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Manifest file references check
|
||||
run: |
|
||||
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <filename> references
|
||||
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILENAMES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <folder> references
|
||||
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Form XML validation
|
||||
run: |
|
||||
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$FORM_FILES" ]; then
|
||||
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
for FILE in $FORM_FILES; do
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
# Check for valid Joomla form structure
|
||||
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Deprecated Joomla API check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||
PATTERNS=(
|
||||
'JFactory::'
|
||||
'JText::'
|
||||
'JHtml::'
|
||||
'JRoute::'
|
||||
'JUri::'
|
||||
'JLog::'
|
||||
'JTable::'
|
||||
'JInput'
|
||||
'CMSFactory::\$application'
|
||||
'JApplicationCms'
|
||||
)
|
||||
|
||||
for PATTERN in "${PATTERNS[@]}"; do
|
||||
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
COUNT=$(echo "$HITS" | wc -l)
|
||||
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Template output escaping check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$TMPL_FILES" ]; then
|
||||
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $TMPL_FILES; do
|
||||
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$UNESCAPED" ]; then
|
||||
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
|
||||
# Check for echo without escaping in template context
|
||||
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$RAW_ECHO" ]; then
|
||||
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Namespace consistency check
|
||||
run: |
|
||||
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find component/plugin manifests with <namespace> tags
|
||||
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $MANIFESTS; do
|
||||
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$NS_PATH" ] && continue
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check PHP files have matching namespace
|
||||
while IFS= read -r -d '' PHP_FILE; do
|
||||
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_NS" ] && continue
|
||||
|
||||
# Namespace should start with the manifest namespace path
|
||||
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SPDX license header check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL=0
|
||||
while IFS= read -r -d '' FILE; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Service provider check
|
||||
run: |
|
||||
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$PROVIDERS" ]; then
|
||||
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for FILE in $PROVIDERS; do
|
||||
# Must return a ServiceProviderInterface
|
||||
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Must have return statement
|
||||
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Script file reference check
|
||||
run: |
|
||||
echo "### Script File Reference" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
SCRIPT_FILE=$(grep -oP '<scriptfile>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$SCRIPT_FILE" ]; then
|
||||
echo "No \`<scriptfile>\` referenced — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ ! -f "${MANIFEST_DIR}/${SCRIPT_FILE}" ]; then
|
||||
echo "::error file=${MANIFEST}::Manifest references \`<scriptfile>${SCRIPT_FILE}</scriptfile>\` but file does not exist"
|
||||
echo "- **Missing** \`${SCRIPT_FILE}\` — referenced in \`<scriptfile>\` but not found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${SCRIPT_FILE}\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} script file issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Script file reference check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Media folder validation
|
||||
run: |
|
||||
echo "### Media Folder Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <media> tag and its folder/filename children
|
||||
MEDIA_DEST=$(grep -oP '<media[^>]*\bdestination="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
MEDIA_FOLDER=$(grep -oP '<media[^>]*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$MEDIA_DEST" ] && [ -z "$MEDIA_FOLDER" ]; then
|
||||
echo "No \`<media>\` tag found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
if [ -n "$MEDIA_FOLDER" ] && [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then
|
||||
echo "::error file=${MANIFEST}::\`<media folder=\"${MEDIA_FOLDER}\">\` references missing directory"
|
||||
echo "- **Missing** media folder \`${MEDIA_FOLDER}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- Media folder \`${MEDIA_FOLDER:-(inline)}\`: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check child references inside <media> block
|
||||
if [ -n "$MEDIA_FOLDER" ]; then
|
||||
MEDIA_FOLDERS=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<folder>\K[^<]+' 2>/dev/null || true)
|
||||
for F in $MEDIA_FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
|
||||
echo "- **Missing** media subfolder \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
MEDIA_FILES=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<filename>\K[^<]+' 2>/dev/null || true)
|
||||
for F in $MEDIA_FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
|
||||
echo "- **Missing** media file \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} media reference issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Media folder validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Target platform check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Target Platform Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Check updates.xml for targetplatform if it exists
|
||||
if [ -f "updates.xml" ]; then
|
||||
if ! grep -q '<targetplatform' "updates.xml" 2>/dev/null; then
|
||||
echo "::warning file=updates.xml::No \`<targetplatform>\` found — Joomla updater cannot filter by compatible version"
|
||||
echo "- **Missing** \`<targetplatform>\` in updates.xml" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- \`<targetplatform>\` in updates.xml: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check manifest for minimum PHP/Joomla version hints
|
||||
if ! grep -qP '<php_minimum>|targetplatform|joomla.*version' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::No minimum Joomla or PHP version constraint found in manifest"
|
||||
echo "- **Missing** version constraints (\`<php_minimum>\` or \`<targetplatform>\`)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
echo "- Version constraints in manifest: present ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} target platform warning(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Target platform check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Changelog URL check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Changelog URL Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
if ! grep -q '<changelogurl>' "$MANIFEST" 2>/dev/null; then
|
||||
echo "::warning file=${MANIFEST}::Missing \`<changelogurl>\` — Joomla updater will not display changelogs"
|
||||
echo "- **Missing** \`<changelogurl>\` — Joomla 4+ shows changelogs in the update manager when this is set" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
else
|
||||
CHANGELOG_URL=$(grep -oP '<changelogurl>\K[^<]+' "$MANIFEST" | head -1)
|
||||
echo "- \`<changelogurl>\`: \`${CHANGELOG_URL}\` ✓" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} changelog URL warning(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Changelog URL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Duplicate file references check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Duplicate File References" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Extract all <filename> and <folder> references
|
||||
ALL_REFS=$(grep -oP '<(filename|folder)[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | sort || true)
|
||||
if [ -z "$ALL_REFS" ]; then
|
||||
echo "No file/folder references found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
DUPES=$(echo "$ALL_REFS" | uniq -d)
|
||||
if [ -n "$DUPES" ]; then
|
||||
while IFS= read -r DUP; do
|
||||
COUNT=$(echo "$ALL_REFS" | grep -cx "$DUP")
|
||||
echo "::warning file=${MANIFEST}::Duplicate reference: \`${DUP}\` appears ${COUNT} times (may be valid if in different sections)"
|
||||
echo "- **Duplicate:** \`${DUP}\` (${COUNT}x) — check if cross-section" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
done <<< "$DUPES"
|
||||
else
|
||||
TOTAL=$(echo "$ALL_REFS" | wc -l)
|
||||
echo "All ${TOTAL} file/folder references are unique." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} duplicate reference(s) found.** Review for cross-section validity." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Duplicate file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Empty language keys check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Empty Language Keys" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$LANG_FILES" ]; then
|
||||
echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL_FILES=0
|
||||
for FILE in $LANG_FILES; do
|
||||
TOTAL_FILES=$((TOTAL_FILES + 1))
|
||||
# Find lines with KEY= but no value (empty or whitespace-only after =)
|
||||
EMPTY_KEYS=$(grep -nP '^[A-Z_]+=\s*$' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$EMPTY_KEYS" ]; then
|
||||
COUNT=$(echo "$EMPTY_KEYS" | wc -l)
|
||||
echo "::warning file=${FILE}::${COUNT} empty language key(s)"
|
||||
echo "- \`${FILE}\`: ${COUNT} empty key(s)" >> $GITHUB_STEP_SUMMARY
|
||||
while IFS= read -r LINE; do
|
||||
LINE_NUM=$(echo "$LINE" | cut -d: -f1)
|
||||
KEY=$(echo "$LINE" | cut -d: -f2 | cut -d= -f1)
|
||||
echo " - Line ${LINE_NUM}: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY
|
||||
done <<< "$EMPTY_KEYS"
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$WARNINGS" -eq 0 ]; then
|
||||
echo "All ${TOTAL_FILES} language file(s) have populated keys." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} empty language key(s) across ${TOTAL_FILES} file(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Empty language keys check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
release-readiness:
|
||||
name: Release Readiness Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate release readiness
|
||||
run: |
|
||||
@@ -338,15 +1092,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP ${{ matrix.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
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -384,14 +1142,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: php -v && composer --version
|
||||
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
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
@@ -448,3 +1211,24 @@ jobs:
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
exit $EXIT
|
||||
|
||||
pre-release:
|
||||
name: Build RC Pre-Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-validate, test]
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Trigger pre-release build
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
@@ -33,17 +33,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/gitleaks.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||
@@ -25,10 +25,6 @@
|
||||
name: "Universal: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.00.01
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
name: Create feature branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||
|
||||
# Check dev branch exists
|
||||
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||
echo "No dev branch -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch from dev
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/branches" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP}" = "201" ]; then
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
|
||||
echo "Commented on issue #${ISSUE_NUM}"
|
||||
else
|
||||
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||
fi
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: MokoStandards.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
@@ -96,6 +96,32 @@ jobs:
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||
gitleaks:
|
||||
name: Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
GITLEAKS_VERSION="8.21.2"
|
||||
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin gitleaks
|
||||
|
||||
- name: Scan PR commits for secrets
|
||||
run: |
|
||||
if gitleaks detect --source . --verbose \
|
||||
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
||||
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::Potential secrets detected in PR commits"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||
|
||||
name: "Joomla: Metadata Validation"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
validate-metadata:
|
||||
name: "Validate Joomla Metadata"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Validate metadata against Joomla manifest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||
--path . \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--api-base "${GITEA_URL}/api/v1" \
|
||||
--ci
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
@@ -60,25 +60,25 @@ jobs:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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/rc-revert.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||
|
||||
name: "RC Revert"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
revert:
|
||||
name: Rename rc/ back to dev/
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == false &&
|
||||
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
SUFFIX="${BRANCH#rc/}"
|
||||
DEV_BRANCH="dev/${SUFFIX}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||
"${API}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "201" ]; then
|
||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -7,8 +7,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
@@ -33,7 +33,8 @@ on:
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
@@ -80,19 +80,3 @@ jobs:
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
|
||||
|
||||
- name: Joomla version audit
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||
rm -f /tmp/sites.json
|
||||
else
|
||||
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||
fi
|
||||
env:
|
||||
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||
# PATH: /.mokogitea/workflows/version-set.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||
|
||||
name: "Joomla: Set Version"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g. 01.00.00)"
|
||||
required: true
|
||||
type: string
|
||||
branch:
|
||||
description: "Branch to update (default: current)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
set-version:
|
||||
name: Set Version to ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update manifest version
|
||||
run: |
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||
else
|
||||
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||
fi
|
||||
|
||||
- name: Update README.md version
|
||||
run: |
|
||||
if [ -f "README.md" ]; then
|
||||
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||
echo "README.md version updated to ${VERSION}"
|
||||
else
|
||||
echo "::warning::No VERSION line found in README.md — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
# Check if this version already has an entry
|
||||
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||
else
|
||||
# Insert new version entry after [Unreleased] or at the top after header
|
||||
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
else
|
||||
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||
fi
|
||||
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||
fi
|
||||
else
|
||||
echo "::warning::No CHANGELOG.md found — skipping"
|
||||
fi
|
||||
|
||||
- name: Update FILE INFORMATION blocks
|
||||
run: |
|
||||
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||
while IFS= read -r -d '' FILE; do
|
||||
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "Moko Consulting [bot]"
|
||||
git config user.email "hello@mokoconsulting.tech"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes detected — nothing to commit"
|
||||
else
|
||||
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||
|
||||
Authored-by: Moko Consulting"
|
||||
git push
|
||||
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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/workflow-sync-trigger.yml
|
||||
# VERSION: 01.01.00
|
||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync workflows to live repos
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
id: platform
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
case "$REPO" in
|
||||
Template-Joomla) PLATFORM="joomla" ;;
|
||||
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||
Template-Go) PLATFORM="go" ;;
|
||||
Template-MCP) PLATFORM="mcp" ;;
|
||||
Template-Generic) PLATFORM="" ;;
|
||||
*) PLATFORM="" ;;
|
||||
esac
|
||||
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 }}
|
||||
run: |
|
||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
|
||||
- name: Run workflow sync
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||
ARGS="${ARGS} --phase repos"
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||
fi
|
||||
|
||||
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||
+53
-8
@@ -5,15 +5,60 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
|
||||
## [1.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Haversine proximity search — filter locations by distance from user's coordinates
|
||||
- Hidden `radius_unit` field in search module to pass miles/km preference to component
|
||||
- Distance-sorted results when proximity search is active
|
||||
- "Get Directions" link on location detail page (Google Maps, no API key needed)
|
||||
- "Get Directions" link in Leaflet map popup markers
|
||||
- Auto-geocoding on admin save — coordinates populated from address via Nominatim/OSM API
|
||||
- CSV import: upload CSV file to bulk-create locations
|
||||
- CSV import: auto-detect column headers (title/name/store, address/street, city, etc.)
|
||||
- CSV import: per-row validation via LocationTable::bind()->check()->store()
|
||||
- CSV import view accessible from admin toolbar and submenu
|
||||
- Language strings for directions, geocoding feedback, and import UI
|
||||
|
||||
## [01.00.00] - 2026-06-23
|
||||
|
||||
### Added
|
||||
- Admin `LocationController` (FormController) for single-record save/cancel/apply
|
||||
- Admin `LocationsController` (AdminController) for bulk publish/unpublish/delete
|
||||
- Admin location edit view and tabbed template (Details, Address, Contact)
|
||||
- Admin locations list renders data rows with edit links and published toggle
|
||||
- `LocationTable::check()` validation: required title, auto-alias, lat/lng range, timestamps
|
||||
- `LocationsModel::populateState()` for filter persistence
|
||||
- Search filter across title, city, state, address
|
||||
- Published state filter and sort ordering support
|
||||
- Filter form XML (`filter_locations.xml`) with search tools bar
|
||||
- Language strings for filters, sort options, and save messages
|
||||
- Site frontend `DisplayController` routing to list and detail views
|
||||
- Site `LocationsModel` — published locations with search, city, and state filters
|
||||
- Site `LocationModel` — single location by ID (published only)
|
||||
- Site locations list view with Schema.org `LocalBusiness` markup and pagination
|
||||
- Site location detail view with address, contact, hours, and map placeholder
|
||||
- SEF URL router (`Service\Router`) with menu/standard/nomenu rules
|
||||
- Menu item types: "All Locations" list and "Location Detail" with location picker
|
||||
- Site language strings for frontend views and menu items
|
||||
- Router registered in service provider and component extension class
|
||||
- Map module dispatcher loads published locations with coordinates from DB
|
||||
- Leaflet.js/OpenStreetMap integration with markers, popups, and auto-fit bounds
|
||||
- Leaflet CSS/JS loaded via Joomla Web Asset Manager (`registerAndUseStyle`/`registerAndUseScript`)
|
||||
- Search module dispatcher loads distinct cities/states and builds radius options
|
||||
- City dropdown filter on search form (populated from DB, toggled by module param)
|
||||
- Radius dropdown filter with configurable distance values and unit (miles/km)
|
||||
- Geolocation "Use My Location" button with browser geolocation API
|
||||
- Hidden lat/lng fields passed to component for proximity search
|
||||
- Language strings for search module (city, radius, geolocation states)
|
||||
|
||||
### Removed
|
||||
- Makefile (no longer used)
|
||||
- deploy-manual.yml workflow
|
||||
|
||||
### Previous (scaffold)
|
||||
- Initial package scaffold with component, map module, and search module
|
||||
- Database schema for locations table with coordinates
|
||||
- Admin MVC for location CRUD
|
||||
- Frontend location listing view with Schema.org markup
|
||||
- Map module with Leaflet/Google Maps provider support
|
||||
- Search module with city and radius filter options
|
||||
- Admin MVC skeleton for location CRUD
|
||||
- Map module with Leaflet/Google Maps provider support (stub)
|
||||
- Search module with city and radius filter options (stub)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MokoSuiteStoreLocator** -- A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | joomla |
|
||||
| **Extension type** | package (component + modules) |
|
||||
| **Element** | `pkg_mokosuitestorelocator` |
|
||||
| **Language** | PHP |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Wiki** | [MokoSuiteStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Package Contents
|
||||
|
||||
| Extension | Type | Element |
|
||||
|---|---|---|
|
||||
| Store Locator Component | component | `com_mokosuitestorelocator` |
|
||||
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
|
||||
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dev dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Joomla package. Key layout:
|
||||
|
||||
- `source/pkg_mokosuitestorelocator.xml` -- package manifest
|
||||
- `source/script.php` -- package install/upgrade/uninstall script
|
||||
- `source/packages/com_mokosuitestorelocator/` -- main component
|
||||
- `admin/` -- admin MVC (controllers, models, views, forms, tables, SQL)
|
||||
- `site/` -- frontend MVC (controllers, models, views, templates)
|
||||
- `mokosuitestorelocator.xml` -- component manifest
|
||||
- `source/packages/mod_mokosuitestorelocator_map/` -- map display module
|
||||
- `source/packages/mod_mokosuitestorelocator_search/` -- search/filter module
|
||||
|
||||
## Database Table
|
||||
|
||||
`#__mokosuitestorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev/`, merge to `main` for release
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
- **PHP minimum**: 8.2
|
||||
- **Joomla minimum**: 5.0
|
||||
- **Joomla table operations**: always use bind() -> check() -> store(), never save()
|
||||
- **Namespace**: `Moko\Component\MokoSuiteStoreLocator` for the component
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP:
|
||||
INGROUP: Project.Documentation
|
||||
REPO:
|
||||
VERSION: 04.04.01
|
||||
VERSION: 01.00.01
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+161
-128
@@ -1,128 +1,161 @@
|
||||
<!--
|
||||
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: {{DEFGROUP}}
|
||||
INGROUP: Project.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
VERSION: 04.04.00
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: How to contribute; branch strategy, commit conventions, PR workflow, and release pipeline
|
||||
-->
|
||||
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to **MokoJoomTOS**!
|
||||
|
||||
This repository is governed by **[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)** — the authoritative source of coding standards, workflows, and policies for all Moko Consulting repositories.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
| Branch | Purpose | Deploys To |
|
||||
|--------|---------|------------|
|
||||
| `main` | Bleeding edge — all development merges here | CI only |
|
||||
| `dev/XX.YY.ZZ` | Feature development | Dev server (version: "development") |
|
||||
| `version/XX.YY` | Stable frozen snapshot | Demo + RS servers |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```
|
||||
1. Create branch: git checkout -b dev/XX.YY.ZZ/my-feature
|
||||
2. Develop + test (dev server auto-deploys on push)
|
||||
3. Open PR → main (squash merge only)
|
||||
4. Auto-release (version branch + tag + GitHub Release created automatically)
|
||||
```
|
||||
|
||||
### Branch Naming
|
||||
|
||||
| Prefix | Use |
|
||||
|--------|-----|
|
||||
| `dev/XX.YY.ZZ` | Feature development (e.g., `dev/02.00.00/add-extrafields`) |
|
||||
| `version/XX.YY` | Stable release (auto-created, never manually pushed) |
|
||||
| `chore/` | Automated sync branches (managed by MokoStandards) |
|
||||
|
||||
> **Never use** `feature/`, `hotfix/`, or `release/` prefixes — they are not part of the MokoStandards branch strategy.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(scope): add new extrafield for invoice tracking
|
||||
fix(sql): correct column type in llx_mytable
|
||||
docs(readme): update installation instructions
|
||||
chore(deps): bump enterprise library to 04.02.30
|
||||
```
|
||||
|
||||
**Valid types:** `feat` | `fix` | `docs` | `chore` | `ci` | `refactor` | `style` | `test` | `perf` | `revert` | `build`
|
||||
|
||||
## Pull Request Workflow
|
||||
|
||||
1. **Branch** from `main` using `dev/XX.YY.ZZ/description` format
|
||||
2. **Bump** the patch version in `README.md` before opening the PR
|
||||
3. **Title** must be a valid conventional commit subject line
|
||||
4. **Target** `main` — squash merge only (merge commits are disabled)
|
||||
5. **CI checks** must pass before merge
|
||||
|
||||
### What Happens on Merge
|
||||
|
||||
When your PR is merged to `main`, these workflows run automatically:
|
||||
|
||||
1. **sync-version-on-merge** — auto-bumps patch version, propagates to all file headers
|
||||
2. **auto-release** — creates `version/XX.YY` branch, git tag, and GitHub Release
|
||||
3. **deploy-demo / deploy-rs** — deploys to demo and RS servers (if `src/**` changed)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
All contributions must follow [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards):
|
||||
|
||||
| Standard | Reference |
|
||||
|----------|-----------|
|
||||
| Coding Style | [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) |
|
||||
| File Headers | [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) |
|
||||
| Branching | [branch-release-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branch-release-strategy.md) |
|
||||
| Merge Strategy | [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) |
|
||||
| Scripting | [scripting-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/scripting-standards.md) |
|
||||
| Build & Release | [build-release.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/build-release.md) |
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Branch named `dev/XX.YY.ZZ/description`
|
||||
- [ ] Patch version bumped in `README.md`
|
||||
- [ ] Conventional commit format for PR title
|
||||
- [ ] All new files have FILE INFORMATION headers
|
||||
- [ ] `declare(strict_types=1)` in all PHP files
|
||||
- [ ] PHPDoc on all public methods
|
||||
- [ ] Tests pass
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] No secrets, tokens, or credentials committed
|
||||
|
||||
## Custom Workflows
|
||||
|
||||
Place repo-specific workflows in `.github/workflows/custom/` — they are **never overwritten or deleted** by MokoStandards sync:
|
||||
|
||||
```
|
||||
.github/workflows/
|
||||
├── deploy-dev.yml ← Synced from MokoStandards
|
||||
├── auto-release.yml ← Synced from MokoStandards
|
||||
└── custom/ ← Your custom workflows (safe)
|
||||
└── my-custom-ci.yml
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0-or-later](LICENSE) license.
|
||||
|
||||
---
|
||||
|
||||
*This file is synced from [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Do not edit directly — changes will be overwritten on the next sync.*
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Makefile for Joomla Extensions
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# This is a reference Makefile for building Joomla extensions.
|
||||
# Copy this to your repository root as "Makefile" and customize as needed.
|
||||
#
|
||||
# Supports: Modules, Plugins, Components, Packages, Templates
|
||||
|
||||
# ==============================================================================
|
||||
# CONFIGURATION - Customize these for your extension
|
||||
# ==============================================================================
|
||||
|
||||
# Extension Configuration
|
||||
EXTENSION_NAME := mokojoomstorelocator
|
||||
EXTENSION_TYPE := package
|
||||
# Options: module, plugin, component, package, template
|
||||
EXTENSION_VERSION := 1.0.0
|
||||
|
||||
# Module Configuration (for modules only)
|
||||
MODULE_TYPE := site
|
||||
# Options: site, admin
|
||||
|
||||
# Plugin Configuration (for plugins only)
|
||||
PLUGIN_GROUP := system
|
||||
# Options: system, content, user, authentication, etc.
|
||||
|
||||
# Directories
|
||||
SRC_DIR := .
|
||||
BUILD_DIR := build
|
||||
DIST_DIR := dist
|
||||
DOCS_DIR := docs
|
||||
|
||||
# Joomla Installation (for local testing - customize paths)
|
||||
JOOMLA_ROOT := /var/www/html/joomla
|
||||
JOOMLA_VERSION := 5
|
||||
|
||||
# Tools
|
||||
PHP := php
|
||||
COMPOSER := composer
|
||||
NPM := npm
|
||||
PHPCS := vendor/bin/phpcs
|
||||
PHPCBF := vendor/bin/phpcbf
|
||||
PHPUNIT := vendor/bin/phpunit
|
||||
ZIP := zip
|
||||
|
||||
# Coding Standards
|
||||
PHPCS_STANDARD := Joomla
|
||||
|
||||
# Colors for output
|
||||
COLOR_RESET := \033[0m
|
||||
COLOR_GREEN := \033[32m
|
||||
COLOR_YELLOW := \033[33m
|
||||
COLOR_BLUE := \033[34m
|
||||
COLOR_RED := \033[31m
|
||||
|
||||
# ==============================================================================
|
||||
# TARGETS
|
||||
# ==============================================================================
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run PHP linter (syntax check)
|
||||
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
|
||||
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
|
||||
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
|
||||
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
|
||||
|
||||
.PHONY: validate
|
||||
validate: lint ## Run all validation checks
|
||||
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
|
||||
@rm -rf $(BUILD_DIR) $(DIST_DIR)
|
||||
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
|
||||
|
||||
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
|
||||
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
|
||||
|
||||
.PHONY: minify
|
||||
minify: ## Minify CSS/JS assets
|
||||
@echo "Minifying assets..."
|
||||
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
|
||||
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
|
||||
elif [ -f "scripts/minify.js" ]; then \
|
||||
node scripts/minify.js; \
|
||||
else \
|
||||
echo "No minify script found"; \
|
||||
fi
|
||||
|
||||
.PHONY: build
|
||||
build: clean validate ## Build package ZIP containing all sub-extensions
|
||||
@echo "$(COLOR_BLUE)Building Joomla package...$(COLOR_RESET)"
|
||||
@mkdir -p $(DIST_DIR) $(BUILD_DIR)/pkg_$(EXTENSION_NAME)
|
||||
@# Build each sub-extension into its own ZIP
|
||||
@for ext in src/packages/*/; do \
|
||||
EXT_NAME=$$(basename $$ext); \
|
||||
echo " Packaging $$EXT_NAME..."; \
|
||||
mkdir -p $(BUILD_DIR)/$$EXT_NAME; \
|
||||
rsync -a --exclude='.git*' "$$ext" "$(BUILD_DIR)/$$EXT_NAME/"; \
|
||||
cd $(BUILD_DIR) && $(ZIP) -r "pkg_$(EXTENSION_NAME)/$$EXT_NAME.zip" "$$EXT_NAME" && cd ..; \
|
||||
done
|
||||
@# Copy the package manifest
|
||||
@cp src/pkg_mokojoomstorelocator.xml $(BUILD_DIR)/pkg_$(EXTENSION_NAME)/
|
||||
@if [ -f "src/script.php" ]; then cp src/script.php $(BUILD_DIR)/pkg_$(EXTENSION_NAME)/; fi
|
||||
@# Create the final package ZIP
|
||||
@cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip" "pkg_$(EXTENSION_NAME)"
|
||||
@echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
|
||||
|
||||
.PHONY: release
|
||||
release: validate build ## Create a release (validate + build)
|
||||
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
|
||||
|
||||
.PHONY: version
|
||||
version: ## Display version information
|
||||
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
|
||||
@echo " Name: $(EXTENSION_NAME)"
|
||||
@echo " Type: $(EXTENSION_TYPE)"
|
||||
@echo " Version: $(EXTENSION_VERSION)"
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -1,34 +1,57 @@
|
||||
# MokoJoomStoreLocator
|
||||
# MokoSuiteStoreLocator
|
||||
|
||||
A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
|
||||
|
||||
## Package Contents
|
||||
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `com_mokojoomstorelocator` | Component for managing store locations (admin CRUD + frontend listing) |
|
||||
| `mod_mokojoomstorelocator_map` | Site module displaying an interactive map with location markers |
|
||||
| `mod_mokojoomstorelocator_search` | Site module providing search/filter form for finding locations |
|
||||
| Extension | Type | Element |
|
||||
|---|---|---|
|
||||
| Store Locator Component | component | `com_mokosuitestorelocator` |
|
||||
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
|
||||
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Joomla 4.4+ or 5.x
|
||||
- PHP 8.1+
|
||||
- MySQL 5.7+ / MariaDB 10.3+
|
||||
- Joomla 5.x or 6.x
|
||||
- PHP 8.2+
|
||||
- MySQL 8.0+ / MariaDB 10.4+
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest `pkg_mokojoomstorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/releases)
|
||||
1. Download the latest `pkg_mokosuitestorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/releases)
|
||||
2. In Joomla Administrator, go to **System > Install > Extensions**
|
||||
3. Upload the package ZIP — all extensions install automatically
|
||||
|
||||
## Features
|
||||
|
||||
- Manage store locations with address, coordinates, contact info, and business hours
|
||||
- Interactive map display (OpenStreetMap/Leaflet or Google Maps)
|
||||
- Location search by city, postcode, or radius
|
||||
- Schema.org LocalBusiness structured data markup
|
||||
- Category support for grouping locations
|
||||
### Implemented
|
||||
- **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image)
|
||||
- **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete
|
||||
- **Site frontend** — locations list and detail views with pagination
|
||||
- **Schema.org** — LocalBusiness structured data markup on all frontend templates
|
||||
- **SEF URLs** — router with menu, standard, and nomenu rules
|
||||
- **Menu items** — "All Locations" list and single "Location Detail" picker
|
||||
- **Interactive map** — Leaflet.js with OpenStreetMap tiles, markers with popups, auto-fit bounds
|
||||
- **Location search** — city dropdown, radius filter, and browser geolocation ("Use My Location")
|
||||
- **Proximity search** — Haversine distance filtering with distance-sorted results
|
||||
- **Get Directions** — Google Maps directions link on detail page and map popups
|
||||
- **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM)
|
||||
- **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping
|
||||
|
||||
### Planned
|
||||
- Marker clustering for dense location areas
|
||||
- Multi-category support with custom map markers
|
||||
- ACL permissions and SQL upgrade schema
|
||||
- REST API via Joomla Web Services plugin
|
||||
- MokoSuiteShop integration for multi-store ecommerce
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dev dependencies
|
||||
```
|
||||
|
||||
Source code lives in `source/packages/` — one directory per sub-extension.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 04.04.01
|
||||
VERSION: 01.00.01
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<form>
|
||||
<fields name="filter">
|
||||
<field
|
||||
name="search"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL"
|
||||
hint="JSEARCH_FILTER"
|
||||
inputmode="search"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="published"
|
||||
type="status"
|
||||
label="JOPTION_SELECT_PUBLISHED"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="">JOPTION_SELECT_PUBLISHED</option>
|
||||
</field>
|
||||
</fields>
|
||||
|
||||
<fields name="list">
|
||||
<field
|
||||
name="fullordering"
|
||||
type="list"
|
||||
label="JGLOBAL_SORT_BY"
|
||||
default="a.title ASC"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="a.title ASC">JGLOBAL_TITLE_ASC</option>
|
||||
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
|
||||
<option value="a.city ASC">COM_MOKOJOOMSTORELOCATOR_CITY_ASC</option>
|
||||
<option value="a.city DESC">COM_MOKOJOOMSTORELOCATOR_CITY_DESC</option>
|
||||
<option value="a.published ASC">JSTATUS_ASC</option>
|
||||
<option value="a.published DESC">JSTATUS_DESC</option>
|
||||
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
|
||||
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="limit"
|
||||
type="limitbox"
|
||||
label="JGLOBAL_LIST_LIMIT"
|
||||
default="25"
|
||||
onchange="this.form.submit();"
|
||||
/>
|
||||
</fields>
|
||||
</form>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Location edit form -->
|
||||
<form>
|
||||
<fieldset name="details" addfieldprefix="Moko\Component\MokoJoomStoreLocator\Administrator\Field">
|
||||
<fieldset name="details" addfieldprefix="Moko\Component\MokoSuiteStoreLocator\Administrator\Field">
|
||||
<field
|
||||
name="id"
|
||||
type="hidden"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
; MokoSuiteStoreLocator - Admin language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
||||
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
|
||||
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_CITY="City"
|
||||
COM_MOKOJOOMSTORELOCATOR_STATE="State"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL="Search Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_CITY_ASC="City ascending"
|
||||
COM_MOKOJOOMSTORELOCATOR_CITY_DESC="City descending"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_SAVE_SUCCESS="Location saved successfully."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_PUBLISHED="%d location(s) published."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_UNPUBLISHED="%d location(s) unpublished."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_DELETED="%d location(s) deleted."
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED="A location title is required."
|
||||
COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE="Latitude must be between -90 and 90."
|
||||
COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE="Longitude must be between -180 and 180."
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS="Coordinates were auto-populated from the address via OpenStreetMap."
|
||||
COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED="Geocoding failed: %s. You can enter coordinates manually."
|
||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_DESC="Import store locations from a CSV file."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPLOAD="Upload CSV File"
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Select a CSV file with location data. First row must be column headers."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER="Delimiter"
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER_DESC="The character separating fields in your CSV file."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS="%d location(s) imported successfully."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED="%d row(s) skipped due to errors."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE="The uploaded file is not a valid CSV."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_ROWS="The CSV file contains no data rows."
|
||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required."
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
; MokoJoomStoreLocator - System language strings
|
||||
; MokoSuiteStoreLocator - System language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
+10
-6
@@ -1,21 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\Router\RouterFactoryInterface;
|
||||
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Moko\Component\MokoJoomStoreLocator\Administrator\Extension\MokoJoomStoreLocatorComponent;
|
||||
use Moko\Component\MokoSuiteStoreLocator\Administrator\Extension\MokoSuiteStoreLocatorComponent;
|
||||
|
||||
/**
|
||||
* The store locator service provider.
|
||||
@@ -35,16 +37,18 @@ return new class implements ServiceProviderInterface
|
||||
*/
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoJoomStoreLocator'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoJoomStoreLocator'));
|
||||
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
|
||||
$container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
|
||||
|
||||
$container->set(
|
||||
ComponentInterface::class,
|
||||
function (Container $container) {
|
||||
$component = new MokoJoomStoreLocatorComponent(
|
||||
$component = new MokoSuiteStoreLocatorComponent(
|
||||
$container->get(ComponentDispatcherFactoryInterface::class)
|
||||
);
|
||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
|
||||
|
||||
return $component;
|
||||
}
|
||||
+2
-2
@@ -2,10 +2,10 @@
|
||||
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
--
|
||||
-- MokoJoomStoreLocator - Store locations table
|
||||
-- MokoSuiteStoreLocator - Store locations table
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL DEFAULT '',
|
||||
`alias` varchar(400) NOT NULL DEFAULT '',
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-- =========================================================================
|
||||
|
||||
DROP TABLE IF EXISTS `#__mokojoomstorelocator_locations`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`;
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/**
|
||||
* Import controller for CSV location uploads.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
class ImportController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Process the uploaded CSV file.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function import(): void
|
||||
{
|
||||
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
// ACL check — user must have create permission
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokosuitestorelocator'))
|
||||
{
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Model\ImportModel $model */
|
||||
$model = $this->getModel('Import', 'Administrator');
|
||||
|
||||
$file = $this->input->files->get('jform', [], 'array');
|
||||
$delimiter = $this->input->post->getString('delimiter', ',');
|
||||
|
||||
$csvFile = $file['csv_file'] ?? null;
|
||||
|
||||
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
|
||||
{
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'csv' && $ext !== 'txt')
|
||||
{
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $model->processImport($csvFile['tmp_name'], $delimiter);
|
||||
|
||||
if ($result['imported'] > 0)
|
||||
{
|
||||
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS', $result['imported']));
|
||||
}
|
||||
|
||||
if ($result['skipped'] > 0)
|
||||
{
|
||||
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\FormController;
|
||||
|
||||
/**
|
||||
* Controller for a single location form.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationController extends FormController
|
||||
{
|
||||
/**
|
||||
* The prefix to use with controller messages.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
|
||||
|
||||
/**
|
||||
* The view list to redirect to after save.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $view_list = 'locations';
|
||||
|
||||
/**
|
||||
* The view item for edit.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $view_item = 'location';
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
|
||||
/**
|
||||
* Locations list controller — handles bulk publish/unpublish/delete.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationsController extends AdminController
|
||||
{
|
||||
/**
|
||||
* The prefix to use with controller messages.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATIONS';
|
||||
|
||||
/**
|
||||
* Proxy for getModel.
|
||||
*
|
||||
* @param string $name The model name.
|
||||
* @param string $prefix The model prefix.
|
||||
* @param array $config Configuration array for model.
|
||||
*
|
||||
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getModel($name = 'Location', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\Router\RouterServiceInterface;
|
||||
use Joomla\CMS\Component\Router\RouterServiceTrait;
|
||||
use Joomla\CMS\Extension\MVCComponent;
|
||||
|
||||
/**
|
||||
* Component class for com_mokosuitestorelocator.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class MokoSuiteStoreLocatorComponent extends MVCComponent implements RouterServiceInterface
|
||||
{
|
||||
use RouterServiceTrait;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use SplFileObject;
|
||||
|
||||
/**
|
||||
* Import model for CSV location processing.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
class ImportModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Known CSV column names mapped to database fields.
|
||||
*
|
||||
* @var array
|
||||
* @since 1.1.0
|
||||
*/
|
||||
private const COLUMN_MAP = [
|
||||
'title' => 'title',
|
||||
'name' => 'title',
|
||||
'store' => 'title',
|
||||
'location' => 'title',
|
||||
'description' => 'description',
|
||||
'address' => 'address',
|
||||
'street' => 'address',
|
||||
'city' => 'city',
|
||||
'state' => 'state',
|
||||
'province' => 'state',
|
||||
'region' => 'state',
|
||||
'postcode' => 'postcode',
|
||||
'zip' => 'postcode',
|
||||
'zipcode' => 'postcode',
|
||||
'postal_code' => 'postcode',
|
||||
'country' => 'country',
|
||||
'latitude' => 'latitude',
|
||||
'lat' => 'latitude',
|
||||
'longitude' => 'longitude',
|
||||
'lng' => 'longitude',
|
||||
'lon' => 'longitude',
|
||||
'phone' => 'phone',
|
||||
'telephone' => 'phone',
|
||||
'email' => 'email',
|
||||
'website' => 'website',
|
||||
'url' => 'website',
|
||||
'hours' => 'hours',
|
||||
'image' => 'image',
|
||||
'published' => 'published',
|
||||
];
|
||||
|
||||
/**
|
||||
* Process a CSV file and import locations.
|
||||
*
|
||||
* @param string $filePath Path to the uploaded CSV file.
|
||||
* @param string $delimiter CSV delimiter character.
|
||||
*
|
||||
* @return array ['imported' => int, 'skipped' => int, 'errors' => array]
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function processImport(string $filePath, string $delimiter = ','): array
|
||||
{
|
||||
$result = ['imported' => 0, 'skipped' => 0, 'errors' => []];
|
||||
|
||||
$file = new SplFileObject($filePath, 'r');
|
||||
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
|
||||
$file->setCsvControl($delimiter);
|
||||
|
||||
// Read and map headers (strip UTF-8 BOM from Excel exports)
|
||||
$headers = $file->fgetcsv();
|
||||
|
||||
if (!empty($headers[0]))
|
||||
{
|
||||
$headers[0] = ltrim($headers[0], "\xEF\xBB\xBF");
|
||||
}
|
||||
|
||||
if (!$headers || \count($headers) < 2)
|
||||
{
|
||||
$result['errors'][] = 'Invalid CSV headers.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$mapping = $this->mapColumns($headers);
|
||||
|
||||
if (!isset($mapping['title']))
|
||||
{
|
||||
$result['errors'][] = 'CSV must contain a "title" or "name" column.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$table = $this->getMVCFactory()->createTable('Location', 'Administrator');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$rowNum = 1;
|
||||
|
||||
foreach ($file as $row)
|
||||
{
|
||||
$rowNum++;
|
||||
|
||||
if (empty($row) || (\count($row) === 1 && $row[0] === null))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $this->mapRowToData($row, $headers, $mapping);
|
||||
|
||||
if (empty($data['title']))
|
||||
{
|
||||
$result['errors'][] = "Row $rowNum: missing title";
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data['published'] = (int) ($data['published'] ?? 1);
|
||||
$data['created_by'] = $user->id;
|
||||
|
||||
// Reset table state for each row
|
||||
$table->reset();
|
||||
$table->id = 0;
|
||||
|
||||
if (!$table->bind($data))
|
||||
{
|
||||
$result['errors'][] = "Row $rowNum: " . $table->getError();
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
$result['errors'][] = "Row $rowNum: " . $table->getError();
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$table->store())
|
||||
{
|
||||
$result['errors'][] = "Row $rowNum: " . $table->getError();
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['imported']++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-map CSV headers to database field names.
|
||||
*
|
||||
* @param array $headers CSV column headers.
|
||||
*
|
||||
* @return array Associative array of db_field => csv_index.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
private function mapColumns(array $headers): array
|
||||
{
|
||||
$mapping = [];
|
||||
|
||||
foreach ($headers as $index => $header)
|
||||
{
|
||||
$normalized = strtolower(trim(str_replace([' ', '-', '_'], ['_', '_', '_'], $header)));
|
||||
|
||||
if (isset(self::COLUMN_MAP[$normalized]))
|
||||
{
|
||||
$dbField = self::COLUMN_MAP[$normalized];
|
||||
|
||||
if (!isset($mapping[$dbField]))
|
||||
{
|
||||
$mapping[$dbField] = $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a CSV row to a data array using the column mapping.
|
||||
*
|
||||
* @param array $row CSV row values.
|
||||
* @param array $headers CSV column headers.
|
||||
* @param array $mapping Column mapping (db_field => csv_index).
|
||||
*
|
||||
* @return array Data array ready for table bind.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
private function mapRowToData(array $row, array $headers, array $mapping): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($mapping as $dbField => $csvIndex)
|
||||
{
|
||||
$data[$dbField] = trim($row[$csvIndex] ?? '');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\Form;
|
||||
use Joomla\CMS\Http\HttpFactory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Model\AdminModel;
|
||||
use Joomla\CMS\Table\Table;
|
||||
|
||||
/**
|
||||
* Single location edit model.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationModel extends AdminModel
|
||||
{
|
||||
/**
|
||||
* The type alias for this content type.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public $typeAlias = 'com_mokosuitestorelocator.location';
|
||||
|
||||
/**
|
||||
* Get the form for this model.
|
||||
*
|
||||
* @param array $data Data for the form.
|
||||
* @param boolean $loadData True if the form is to load its own data.
|
||||
*
|
||||
* @return Form|boolean A Form object on success, false on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokosuitestorelocator.location',
|
||||
'location',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
if (empty($form))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the data for the form.
|
||||
*
|
||||
* @return mixed The data for the form.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function loadFormData()
|
||||
{
|
||||
$data = $this->getItem();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table for this model.
|
||||
*
|
||||
* @param string $name The table name.
|
||||
* @param string $prefix The table prefix.
|
||||
* @param array $options Configuration array for the table.
|
||||
*
|
||||
* @return Table
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the location, auto-geocoding the address if coordinates are empty.
|
||||
*
|
||||
* @param array $data The form data.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function save($data)
|
||||
{
|
||||
$hasCoords = isset($data['latitude'], $data['longitude'])
|
||||
&& is_numeric($data['latitude']) && is_numeric($data['longitude']);
|
||||
$hasAddress = !empty($data['address']) || !empty($data['city']) || !empty($data['postcode']);
|
||||
|
||||
if (!$hasCoords && $hasAddress)
|
||||
{
|
||||
$coords = $this->geocodeAddress($data);
|
||||
|
||||
if ($coords)
|
||||
{
|
||||
$data['latitude'] = $coords['lat'];
|
||||
$data['longitude'] = $coords['lng'];
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::_('COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS'),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::save($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode an address using the Nominatim (OpenStreetMap) API.
|
||||
*
|
||||
* @param array $data Location data with address fields.
|
||||
*
|
||||
* @return array|null ['lat' => float, 'lng' => float] or null on failure.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
private function geocodeAddress(array $data): ?array
|
||||
{
|
||||
$parts = array_filter([
|
||||
$data['address'] ?? '',
|
||||
$data['city'] ?? '',
|
||||
$data['state'] ?? '',
|
||||
$data['postcode'] ?? '',
|
||||
$data['country'] ?? '',
|
||||
]);
|
||||
|
||||
if (empty($parts))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = implode(', ', $parts);
|
||||
|
||||
try
|
||||
{
|
||||
$http = HttpFactory::getHttp();
|
||||
$url = 'https://nominatim.openstreetmap.org/search?'
|
||||
. http_build_query(['format' => 'json', 'limit' => 1, 'q' => $query]);
|
||||
$response = $http->get($url, ['User-Agent' => 'MokoSuiteStoreLocator/1.1 (+https://mokoconsulting.tech)'], 10);
|
||||
|
||||
if ($response->code !== 200)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$results = json_decode($response->body, true);
|
||||
|
||||
if (isset($results[0]['lat']) && is_numeric($results[0]['lat'])
|
||||
&& isset($results[0]['lon']) && is_numeric($results[0]['lon']))
|
||||
{
|
||||
return [
|
||||
'lat' => round((float) $results[0]['lat'], 8),
|
||||
'lng' => round((float) $results[0]['lon'], 8),
|
||||
];
|
||||
}
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED', $e->getMessage()),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
/**
|
||||
* Locations list model for the admin view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationsModel extends ListModel
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config An optional associative array of configuration settings.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields']))
|
||||
{
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'title', 'a.title',
|
||||
'city', 'a.city',
|
||||
'state', 'a.state',
|
||||
'published', 'a.published',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the model state.
|
||||
*
|
||||
* @param string $ordering Default ordering column.
|
||||
* @param string $direction Default ordering direction.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function populateState($ordering = 'a.title', $direction = 'ASC')
|
||||
{
|
||||
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
|
||||
$this->setState('filter.search', $search);
|
||||
|
||||
$published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string');
|
||||
$this->setState('filter.published', $published);
|
||||
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SQL query to load the list data.
|
||||
*
|
||||
* @return QueryInterface
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'));
|
||||
|
||||
// Filter by published state
|
||||
$published = $this->getState('filter.published');
|
||||
|
||||
if (is_numeric($published))
|
||||
{
|
||||
$query->where($db->quoteName('a.published') . ' = :published')
|
||||
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
|
||||
}
|
||||
elseif ($published === '')
|
||||
{
|
||||
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
|
||||
}
|
||||
|
||||
// Search filter
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search))
|
||||
{
|
||||
$search = '%' . trim($search) . '%';
|
||||
$query->where(
|
||||
'(' . $db->quoteName('a.title') . ' LIKE :search'
|
||||
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
|
||||
. ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
|
||||
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
|
||||
)
|
||||
->bind(':search', $search)
|
||||
->bind(':search2', $search)
|
||||
->bind(':search3', $search)
|
||||
->bind(':search4', $search);
|
||||
}
|
||||
|
||||
// Ordering
|
||||
$orderCol = $this->state->get('list.ordering', 'a.title');
|
||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Filter\OutputFilter;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Location table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database driver object.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokosuitestorelocator_locations', 'id', $db);
|
||||
|
||||
$this->setColumnAlias('published', 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded check method to ensure data integrity.
|
||||
*
|
||||
* @return boolean True if the data is valid.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (trim($this->title) === '')
|
||||
{
|
||||
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trim($this->alias) === '')
|
||||
{
|
||||
$this->alias = $this->title;
|
||||
}
|
||||
|
||||
$this->alias = OutputFilter::stringURLSafe($this->alias);
|
||||
|
||||
if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90))
|
||||
{
|
||||
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180))
|
||||
{
|
||||
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = Factory::getDate()->toSql();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!(int) $this->id)
|
||||
{
|
||||
if (!$this->created || $this->created === '0000-00-00 00:00:00')
|
||||
{
|
||||
$this->created = $now;
|
||||
}
|
||||
|
||||
if (!$this->created_by)
|
||||
{
|
||||
$this->created_by = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->modified = $now;
|
||||
$this->modified_by = $user->id;
|
||||
|
||||
return parent::check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Import;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
/**
|
||||
* Import view for CSV upload.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* Display the import form.
|
||||
*
|
||||
* @param string $tpl The template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT'), 'upload');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Location;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
/**
|
||||
* Location edit view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* The form object.
|
||||
*
|
||||
* @var \Joomla\CMS\Form\Form
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $form;
|
||||
|
||||
/**
|
||||
* The item being edited.
|
||||
*
|
||||
* @var object
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $item;
|
||||
|
||||
/**
|
||||
* Display the view.
|
||||
*
|
||||
* @param string $tpl The template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->form = $this->get('Form');
|
||||
$this->item = $this->get('Item');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the page title and toolbar.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
Factory::getApplication()->input->set('hidemainmenu', true);
|
||||
|
||||
$isNew = ($this->item->id == 0);
|
||||
|
||||
ToolbarHelper::title(
|
||||
Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATION_' . ($isNew ? 'NEW' : 'EDIT')),
|
||||
'location'
|
||||
);
|
||||
|
||||
ToolbarHelper::apply('location.apply');
|
||||
ToolbarHelper::save('location.save');
|
||||
ToolbarHelper::save2new('location.save2new');
|
||||
|
||||
if (!$isNew)
|
||||
{
|
||||
ToolbarHelper::save2copy('location.save2copy');
|
||||
}
|
||||
|
||||
ToolbarHelper::cancel('location.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||
}
|
||||
}
|
||||
+23
-13
@@ -1,15 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Locations;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
@@ -21,29 +22,35 @@ use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* The list of locations.
|
||||
*
|
||||
* @var array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $items;
|
||||
|
||||
/**
|
||||
* The pagination object.
|
||||
*
|
||||
* @var \Joomla\CMS\Pagination\Pagination
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $pagination;
|
||||
|
||||
/**
|
||||
* The model state.
|
||||
*
|
||||
* @var \Joomla\Registry\Registry
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $state;
|
||||
|
||||
/**
|
||||
* @var \Joomla\CMS\Form\Form
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public $filterForm;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public $activeFilters;
|
||||
|
||||
/**
|
||||
* Display the view.
|
||||
*
|
||||
@@ -55,9 +62,11 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->filterForm = $this->get('FilterForm');
|
||||
$this->activeFilters = $this->get('ActiveFilters');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
@@ -73,10 +82,11 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('Store Locator: Locations');
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'), 'location');
|
||||
ToolbarHelper::addNew('location.add');
|
||||
ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
|
||||
ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
|
||||
ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
|
||||
ToolbarHelper::custom('import.display', 'upload', '', 'COM_MOKOJOOMSTORELOCATOR_IMPORT', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Import\HtmlView $this */
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=import.import'); ?>"
|
||||
method="post" enctype="multipart/form-data" class="form-validate">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPLOAD'); ?></h3>
|
||||
<p class="text-muted"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC'); ?></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="csv_file" class="form-label">
|
||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?>
|
||||
</label>
|
||||
<input type="file" name="jform[csv_file]" id="csv_file"
|
||||
class="form-control" accept=".csv,text/csv" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="delimiter" class="form-label">
|
||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER'); ?>
|
||||
</label>
|
||||
<select name="delimiter" id="delimiter" class="form-select" style="width: auto;">
|
||||
<option value=","><?php echo Text::_('Comma (,)'); ?></option>
|
||||
<option value=";"><?php echo Text::_('Semicolon (;)'); ?></option>
|
||||
<option value="|"><?php echo Text::_('Pipe (|)'); ?></option>
|
||||
<option value="	"><?php echo Text::_('Tab'); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT'); ?>
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
|
||||
class="btn btn-secondary ms-2">
|
||||
<?php echo Text::_('JCANCEL'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4><?php echo Text::_('JHELP'); ?></h4>
|
||||
<p>Supported column headers (auto-detected):</p>
|
||||
<ul class="small">
|
||||
<li><strong>title</strong> / name / store / location (required)</li>
|
||||
<li>address / street</li>
|
||||
<li>city</li>
|
||||
<li>state / province / region</li>
|
||||
<li>postcode / zip / zipcode / postal_code</li>
|
||||
<li>country</li>
|
||||
<li>latitude / lat</li>
|
||||
<li>longitude / lng / lon</li>
|
||||
<li>phone / telephone</li>
|
||||
<li>email</li>
|
||||
<li>website / url</li>
|
||||
<li>hours</li>
|
||||
<li>published (0 or 1)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Location\HtmlView $this */
|
||||
|
||||
HTMLHelper::_('behavior.formvalidator');
|
||||
HTMLHelper::_('behavior.keepalive');
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
|
||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<?php echo $this->form->renderField('title'); ?>
|
||||
<?php echo $this->form->renderField('alias'); ?>
|
||||
<?php echo $this->form->renderField('description'); ?>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<?php echo $this->form->renderField('published'); ?>
|
||||
<?php echo $this->form->renderField('image'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<?php echo $this->form->renderField('address'); ?>
|
||||
<?php echo $this->form->renderField('city'); ?>
|
||||
<?php echo $this->form->renderField('state'); ?>
|
||||
<?php echo $this->form->renderField('postcode'); ?>
|
||||
<?php echo $this->form->renderField('country'); ?>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<?php echo $this->form->renderField('latitude'); ?>
|
||||
<?php echo $this->form->renderField('longitude'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'contact', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT')); ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<?php echo $this->form->renderField('phone'); ?>
|
||||
<?php echo $this->form->renderField('email'); ?>
|
||||
<?php echo $this->form->renderField('website'); ?>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<?php echo $this->form->renderField('hours'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||
|
||||
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
||||
|
||||
<input type="hidden" name="task" value="">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
+33
-5
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
@@ -13,14 +13,16 @@ use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations\HtmlView $this */
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Locations\HtmlView $this */
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
|
||||
method="post" name="adminForm" id="adminForm">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="j-main-container" class="j-main-container">
|
||||
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
@@ -54,7 +56,33 @@ use Joomla\CMS\Router\Route;
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- TODO: Render location rows -->
|
||||
<?php foreach ($this->items as $i => $item) : ?>
|
||||
<tr class="row<?php echo $i % 2; ?>">
|
||||
<td class="w-1 text-center">
|
||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
|
||||
</td>
|
||||
<th scope="row">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=location.edit&id=' . (int) $item->id); ?>">
|
||||
<?php echo $this->escape($item->title); ?>
|
||||
</a>
|
||||
<?php if ($item->alias) : ?>
|
||||
<div class="small"><?php echo $this->escape($item->alias); ?></div>
|
||||
<?php endif; ?>
|
||||
</th>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<?php echo $this->escape($item->city); ?>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<?php echo $this->escape($item->state); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+9
-8
@@ -6,17 +6,17 @@
|
||||
|
||||
=========================================================================
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoJoomStoreLocator
|
||||
INGROUP: com_mokojoomstorelocator
|
||||
PATH: src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml
|
||||
DEFGROUP: MokoSuiteStoreLocator
|
||||
INGROUP: com_mokosuitestorelocator
|
||||
PATH: src/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Component manifest for the store locator component
|
||||
=========================================================================
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokojoomstorelocator</name>
|
||||
<version>1.0.0</version>
|
||||
<creationDate>2026-05-21</creationDate>
|
||||
<name>com_mokosuitestorelocator</name>
|
||||
<version>01.00.01</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
@@ -24,7 +24,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<description>COM_MOKOJOOMSTORELOCATOR_DESC</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoJoomStoreLocator</namespace>
|
||||
<namespace path="src">Moko\Component\MokoSuiteStoreLocator</namespace>
|
||||
|
||||
<install>
|
||||
<sql>
|
||||
@@ -56,7 +56,8 @@
|
||||
|
||||
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokojoomstorelocator&view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
|
||||
<menu link="option=com_mokosuitestorelocator&view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
|
||||
<menu link="option=com_mokosuitestorelocator&view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
|
||||
</submenu>
|
||||
</administration>
|
||||
</extension>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
; MokoSuiteStoreLocator - Site language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE="All Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC="Displays a list of all store locations."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE="Location Detail"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC="Displays a single store location with full details."
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION="Select Location"
|
||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Default site controller.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
/**
|
||||
* The default view.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $default_view = 'locations';
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ItemModel;
|
||||
use Joomla\Database\ParameterType;
|
||||
|
||||
/**
|
||||
* Single location model for the site frontend.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationModel extends ItemModel
|
||||
{
|
||||
/**
|
||||
* The location item.
|
||||
*
|
||||
* @var object|null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $_item = null;
|
||||
|
||||
/**
|
||||
* Get a single location item.
|
||||
*
|
||||
* @param integer $pk The item primary key. If null, uses the model state.
|
||||
*
|
||||
* @return object|null The location object or null if not found.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItem($pk = null)
|
||||
{
|
||||
$pk = $pk ?: (int) $this->getState('location.id');
|
||||
|
||||
if ($this->_item === null)
|
||||
{
|
||||
$this->_item = [];
|
||||
}
|
||||
|
||||
if (!isset($this->_item[$pk]))
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
|
||||
->where($db->quoteName('a.id') . ' = :pk')
|
||||
->where($db->quoteName('a.published') . ' = 1')
|
||||
->bind(':pk', $pk, ParameterType::INTEGER);
|
||||
|
||||
$db->setQuery($query);
|
||||
$this->_item[$pk] = $db->loadObject();
|
||||
}
|
||||
|
||||
return $this->_item[$pk] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the model state.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function populateState()
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
$id = $app->input->getInt('id', 0);
|
||||
$this->setState('location.id', $id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
use Joomla\Database\ParameterType;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
/**
|
||||
* Locations list model for the site frontend.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationsModel extends ListModel
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration settings.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields']))
|
||||
{
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'title', 'a.title',
|
||||
'city', 'a.city',
|
||||
'state', 'a.state',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the model state.
|
||||
*
|
||||
* @param string $ordering Default ordering column.
|
||||
* @param string $direction Default ordering direction.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
$search = $app->input->getString('search', '');
|
||||
$this->setState('filter.search', $search);
|
||||
|
||||
$city = $app->input->getString('city', '');
|
||||
$this->setState('filter.city', $city);
|
||||
|
||||
$state = $app->input->getString('state', '');
|
||||
$this->setState('filter.state', $state);
|
||||
|
||||
$latRaw = $app->input->getString('lat', '');
|
||||
$lngRaw = $app->input->getString('lng', '');
|
||||
$radius = $app->input->getInt('radius', 0);
|
||||
$radiusUnit = $app->input->getString('radius_unit', 'miles');
|
||||
|
||||
if ($latRaw !== '' && is_numeric($latRaw))
|
||||
{
|
||||
$lat = (float) $latRaw;
|
||||
|
||||
if ($lat >= -90 && $lat <= 90)
|
||||
{
|
||||
$this->setState('filter.lat', $lat);
|
||||
}
|
||||
}
|
||||
|
||||
if ($lngRaw !== '' && is_numeric($lngRaw))
|
||||
{
|
||||
$lng = (float) $lngRaw;
|
||||
|
||||
if ($lng >= -180 && $lng <= 180)
|
||||
{
|
||||
$this->setState('filter.lng', $lng);
|
||||
}
|
||||
}
|
||||
|
||||
if ($radius > 0 && $radius <= 25000)
|
||||
{
|
||||
$this->setState('filter.radius', $radius);
|
||||
}
|
||||
|
||||
$this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles');
|
||||
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the query for the locations list.
|
||||
*
|
||||
* @return QueryInterface
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
|
||||
->where($db->quoteName('a.published') . ' = 1');
|
||||
|
||||
// Search filter
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search))
|
||||
{
|
||||
$search = '%' . trim($search) . '%';
|
||||
$query->where(
|
||||
'(' . $db->quoteName('a.title') . ' LIKE :search'
|
||||
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
|
||||
. ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
|
||||
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
|
||||
)
|
||||
->bind(':search', $search)
|
||||
->bind(':search2', $search)
|
||||
->bind(':search3', $search)
|
||||
->bind(':search4', $search);
|
||||
}
|
||||
|
||||
// City filter
|
||||
$city = $this->getState('filter.city');
|
||||
|
||||
if (!empty($city))
|
||||
{
|
||||
$query->where($db->quoteName('a.city') . ' = :city')
|
||||
->bind(':city', $city);
|
||||
}
|
||||
|
||||
// State filter
|
||||
$state = $this->getState('filter.state');
|
||||
|
||||
if (!empty($state))
|
||||
{
|
||||
$query->where($db->quoteName('a.state') . ' = :state')
|
||||
->bind(':state', $state);
|
||||
}
|
||||
|
||||
// Proximity / Haversine distance filter
|
||||
$lat = $this->getState('filter.lat');
|
||||
$lng = $this->getState('filter.lng');
|
||||
$radius = $this->getState('filter.radius');
|
||||
|
||||
if ($lat !== null && $lng !== null && $radius)
|
||||
{
|
||||
$unit = $this->getState('filter.radius_unit', 'miles');
|
||||
$earthRadius = ($unit === 'km') ? 6371 : 3959;
|
||||
|
||||
$haversine = '(' . $earthRadius . ' * ACOS(LEAST(1, GREATEST(-1, '
|
||||
. 'SIN(RADIANS(' . $db->quoteName('a.latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) '
|
||||
. '+ COS(RADIANS(' . $db->quoteName('a.latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) '
|
||||
. '* COS(RADIANS(' . $db->quoteName('a.longitude') . ' - ' . (float) $lng . '))'
|
||||
. '))))';
|
||||
|
||||
$query->where($db->quoteName('a.latitude') . ' IS NOT NULL')
|
||||
->where($db->quoteName('a.longitude') . ' IS NOT NULL')
|
||||
->where($haversine . ' <= ' . (int) $radius);
|
||||
|
||||
$query->select($haversine . ' AS distance');
|
||||
$query->order('distance ASC');
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default ordering
|
||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Application\SiteApplication;
|
||||
use Joomla\CMS\Component\Router\RouterView;
|
||||
use Joomla\CMS\Component\Router\RouterViewConfiguration;
|
||||
use Joomla\CMS\Component\Router\Rules\MenuRules;
|
||||
use Joomla\CMS\Component\Router\Rules\NomenuRules;
|
||||
use Joomla\CMS\Component\Router\Rules\StandardRules;
|
||||
use Joomla\CMS\Menu\AbstractMenu;
|
||||
|
||||
/**
|
||||
* SEF URL router for the store locator component.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Router extends RouterView
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param SiteApplication $app The application object.
|
||||
* @param AbstractMenu $menu The menu object.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(SiteApplication $app, AbstractMenu $menu)
|
||||
{
|
||||
$locations = new RouterViewConfiguration('locations');
|
||||
$this->registerView($locations);
|
||||
|
||||
$location = new RouterViewConfiguration('location');
|
||||
$location->setKey('id')->setParent($locations);
|
||||
$this->registerView($location);
|
||||
|
||||
parent::__construct($app, $menu);
|
||||
|
||||
$this->attachRule(new MenuRules($this));
|
||||
$this->attachRule(new StandardRules($this));
|
||||
$this->attachRule(new NomenuRules($this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\View\Location;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Single location detail view for the site frontend.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var object|null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $item;
|
||||
|
||||
/**
|
||||
* Display the view.
|
||||
*
|
||||
* @param string $tpl The template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->item = $this->get('Item');
|
||||
|
||||
if ($this->item === null)
|
||||
{
|
||||
throw new \Exception('Location not found', 404);
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteStoreLocator\Site\View\Locations;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Locations list view for the site frontend.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $items;
|
||||
|
||||
/**
|
||||
* @var \Joomla\CMS\Pagination\Pagination
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $pagination;
|
||||
|
||||
/**
|
||||
* @var \Joomla\Registry\Registry
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $state;
|
||||
|
||||
/**
|
||||
* Display the view.
|
||||
*
|
||||
* @param string $tpl The template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Location\HtmlView $this */
|
||||
|
||||
$item = $this->item;
|
||||
?>
|
||||
<div class="com-mokosuitestorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
|
||||
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
|
||||
|
||||
<?php if ($item->image) : ?>
|
||||
<div class="com-mokosuitestorelocator-location__image">
|
||||
<img src="<?php echo $this->escape($item->image); ?>"
|
||||
alt="<?php echo $this->escape($item->title); ?>"
|
||||
itemprop="image">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($item->description) : ?>
|
||||
<div class="com-mokosuitestorelocator-location__description" itemprop="description">
|
||||
<?php echo HTMLHelper::_('content.prepare', $item->description); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="com-mokosuitestorelocator-location__details">
|
||||
<div class="com-mokosuitestorelocator-location__address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
|
||||
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS'); ?></h3>
|
||||
<?php if ($item->address) : ?>
|
||||
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
|
||||
<?php endif; ?>
|
||||
<?php if ($item->city) : ?>
|
||||
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
|
||||
<?php endif; ?>
|
||||
<?php if ($item->state) : ?>
|
||||
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($item->postcode) : ?>
|
||||
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($item->country) : ?>
|
||||
<br><span itemprop="addressCountry"><?php echo $this->escape($item->country); ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($item->latitude && $item->longitude) : ?>
|
||||
<div class="com-mokosuitestorelocator-location__directions mt-2">
|
||||
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
target="_blank" rel="noopener"
|
||||
data-directions
|
||||
data-lat="<?php echo (float) $item->latitude; ?>"
|
||||
data-lng="<?php echo (float) $item->longitude; ?>"
|
||||
data-title="<?php echo $this->escape($item->title); ?>">
|
||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="com-mokosuitestorelocator-location__contact">
|
||||
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h3>
|
||||
<?php if ($item->phone) : ?>
|
||||
<div>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE'); ?>:</strong>
|
||||
<?php $safePhone = preg_replace('/[^0-9+\-() ]/', '', $item->phone); ?>
|
||||
<a href="tel:<?php echo $this->escape($safePhone); ?>" itemprop="telephone">
|
||||
<?php echo $this->escape($item->phone); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($item->email) : ?>
|
||||
<div>
|
||||
<strong><?php echo Text::_('JGLOBAL_EMAIL'); ?>:</strong>
|
||||
<a href="mailto:<?php echo $this->escape($item->email); ?>" itemprop="email">
|
||||
<?php echo $this->escape($item->email); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($item->website && preg_match('#^https?://#i', $item->website)) : ?>
|
||||
<div>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE'); ?>:</strong>
|
||||
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
|
||||
<?php echo $this->escape($item->website); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($item->hours) : ?>
|
||||
<div class="com-mokosuitestorelocator-location__hours">
|
||||
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS'); ?></h3>
|
||||
<div itemprop="openingHours">
|
||||
<?php echo nl2br($this->escape($item->hours)); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($item->latitude && $item->longitude) : ?>
|
||||
<meta itemprop="latitude" content="<?php echo $this->escape($item->latitude); ?>">
|
||||
<meta itemprop="longitude" content="<?php echo $this->escape($item->longitude); ?>">
|
||||
<div class="com-mokosuitestorelocator-location__map"
|
||||
data-lat="<?php echo $this->escape($item->latitude); ?>"
|
||||
data-lng="<?php echo $this->escape($item->longitude); ?>"
|
||||
data-title="<?php echo $this->escape($item->title); ?>"
|
||||
style="height: 300px;">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE"
|
||||
option="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC">
|
||||
<message>
|
||||
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC]]>
|
||||
</message>
|
||||
</layout>
|
||||
<fields name="request">
|
||||
<fieldset name="request">
|
||||
<field
|
||||
name="id"
|
||||
type="sql"
|
||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION"
|
||||
query="SELECT id, title FROM #__mokosuitestorelocator_locations WHERE published = 1 ORDER BY title"
|
||||
key_field="id"
|
||||
value_field="title"
|
||||
required="true"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage com_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Locations\HtmlView $this */
|
||||
?>
|
||||
<div class="com-mokosuitestorelocator-locations">
|
||||
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<p><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS'); ?></p>
|
||||
<?php else : ?>
|
||||
<div class="com-mokosuitestorelocator-locations__list">
|
||||
<?php foreach ($this->items as $item) : ?>
|
||||
<div class="com-mokosuitestorelocator-location-card" itemscope itemtype="https://schema.org/LocalBusiness">
|
||||
<?php if ($item->image) : ?>
|
||||
<div class="com-mokosuitestorelocator-location-card__image">
|
||||
<img src="<?php echo $this->escape($item->image); ?>"
|
||||
alt="<?php echo $this->escape($item->title); ?>"
|
||||
itemprop="image" loading="lazy">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="com-mokosuitestorelocator-location-card__body">
|
||||
<h3 itemprop="name">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=location&id=' . (int) $item->id); ?>">
|
||||
<?php echo $this->escape($item->title); ?>
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
|
||||
<?php if ($item->address) : ?>
|
||||
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
|
||||
<?php endif; ?>
|
||||
<?php if ($item->city) : ?>
|
||||
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
|
||||
<?php endif; ?>
|
||||
<?php if ($item->state) : ?>
|
||||
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($item->postcode) : ?>
|
||||
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($item->phone) : ?>
|
||||
<div class="com-mokosuitestorelocator-location-card__phone">
|
||||
<?php $safePhone = preg_replace('/[^0-9+\-() ]/', '', $item->phone); ?>
|
||||
<a href="tel:<?php echo $this->escape($safePhone); ?>" itemprop="telephone">
|
||||
<?php echo $this->escape($item->phone); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE"
|
||||
option="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC">
|
||||
<message>
|
||||
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC]]>
|
||||
</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
; MokoJoomStoreLocator Map Module - Language strings
|
||||
; MokoSuiteStoreLocator Map Module - Language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
; MokoJoomStoreLocator Map Module - System language strings
|
||||
; MokoSuiteStoreLocator Map Module - System language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
+7
-7
@@ -5,17 +5,17 @@
|
||||
|
||||
=========================================================================
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoJoomStoreLocator
|
||||
INGROUP: mod_mokojoomstorelocator_map
|
||||
PATH: src/packages/mod_mokojoomstorelocator_map/mod_mokojoomstorelocator_map.xml
|
||||
DEFGROUP: MokoSuiteStoreLocator
|
||||
INGROUP: mod_mokosuitestorelocator_map
|
||||
PATH: src/packages/mod_mokosuitestorelocator_map/mod_mokosuitestorelocator_map.xml
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Module manifest for the store locator map module
|
||||
=========================================================================
|
||||
-->
|
||||
<extension type="module" client="site" method="upgrade">
|
||||
<name>mod_mokojoomstorelocator_map</name>
|
||||
<version>1.0.0</version>
|
||||
<creationDate>2026-05-21</creationDate>
|
||||
<name>mod_mokosuitestorelocator_map</name>
|
||||
<version>01.00.01</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
@@ -23,7 +23,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<description>MOD_MOKOJOOMSTORELOCATOR_MAP_DESC</description>
|
||||
|
||||
<namespace path="src">Moko\Module\MokoJoomStoreLocatorMap</namespace>
|
||||
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorMap</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage mod_mokosuitestorelocator_map
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoSuiteStoreLocatorMap\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||
use Joomla\Database\DatabaseAwareInterface;
|
||||
use Joomla\Database\DatabaseAwareTrait;
|
||||
|
||||
/**
|
||||
* Dispatcher for mod_mokosuitestorelocator_map.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface, DatabaseAwareInterface
|
||||
{
|
||||
use HelperFactoryAwareTrait;
|
||||
use DatabaseAwareTrait;
|
||||
|
||||
/**
|
||||
* Returns the layout data.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select([
|
||||
$db->quoteName('id'),
|
||||
$db->quoteName('title'),
|
||||
$db->quoteName('address'),
|
||||
$db->quoteName('city'),
|
||||
$db->quoteName('state'),
|
||||
$db->quoteName('postcode'),
|
||||
$db->quoteName('phone'),
|
||||
$db->quoteName('latitude'),
|
||||
$db->quoteName('longitude'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('latitude') . ' IS NOT NULL')
|
||||
->where($db->quoteName('longitude') . ' IS NOT NULL');
|
||||
|
||||
$db->setQuery($query);
|
||||
$locations = $db->loadObjectList() ?: [];
|
||||
|
||||
$markers = [];
|
||||
|
||||
foreach ($locations as $loc)
|
||||
{
|
||||
$markers[] = [
|
||||
'id' => (int) $loc->id,
|
||||
'title' => $loc->title,
|
||||
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
|
||||
'phone' => $loc->phone,
|
||||
'lat' => (float) $loc->latitude,
|
||||
'lng' => (float) $loc->longitude,
|
||||
];
|
||||
}
|
||||
|
||||
$data['locations'] = $markers;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage mod_mokosuitestorelocator_map
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/** @var array $displayData */
|
||||
$params = $displayData['params'];
|
||||
$locations = $displayData['locations'] ?? [];
|
||||
$moduleId = $displayData['module']->id;
|
||||
$mapHeight = $params->get('map_height', '400px');
|
||||
$mapZoom = (int) $params->get('map_zoom', 10);
|
||||
$provider = $params->get('map_provider', 'leaflet');
|
||||
$apiKey = $params->get('api_key', '');
|
||||
|
||||
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
|
||||
$wa = $displayData['app']->getDocument()->getWebAssetManager();
|
||||
|
||||
if ($provider === 'leaflet')
|
||||
{
|
||||
$wa->registerAndUseStyle('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], ['integrity' => 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=', 'crossorigin' => '']);
|
||||
$wa->registerAndUseScript('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', [], ['integrity' => 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', 'crossorigin' => '', 'defer' => true]);
|
||||
}
|
||||
?>
|
||||
<div class="mod-mokosuitestorelocator-map"
|
||||
id="mokosuitestorelocator-map-<?php echo (int) $moduleId; ?>"
|
||||
style="height: <?php echo $this->escape($mapHeight); ?>;"
|
||||
data-locations='<?php echo json_encode($locations, JSON_HEX_APOS | JSON_HEX_TAG); ?>'
|
||||
data-zoom="<?php echo $mapZoom; ?>"
|
||||
data-provider="<?php echo $this->escape($provider); ?>"
|
||||
<?php if ($apiKey) : ?>data-api-key="<?php echo $this->escape($apiKey); ?>"<?php endif; ?>>
|
||||
<noscript>
|
||||
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('mokosuitestorelocator-map-<?php echo (int) $moduleId; ?>');
|
||||
if (!el || typeof L === 'undefined') return;
|
||||
|
||||
var locations = JSON.parse(el.getAttribute('data-locations') || '[]');
|
||||
var zoom = parseInt(el.getAttribute('data-zoom') || '10', 10);
|
||||
|
||||
var map = L.map(el.id);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
if (locations.length === 0) {
|
||||
map.setView([39.8283, -98.5795], 4);
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = L.latLngBounds();
|
||||
|
||||
function esc(str) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str || ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
locations.forEach(function(loc) {
|
||||
var marker = L.marker([loc.lat, loc.lng]).addTo(map);
|
||||
var popup = '<strong>' + esc(loc.title) + '</strong>';
|
||||
if (loc.address) popup += '<br>' + esc(loc.address);
|
||||
if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>';
|
||||
popup += '<br><a href="https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng + '" target="_blank" rel="noopener"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true); ?></a>';
|
||||
marker.bindPopup(popup);
|
||||
bounds.extend([loc.lat, loc.lng]);
|
||||
});
|
||||
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: zoom });
|
||||
});
|
||||
</script>
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
; MokoJoomStoreLocator Search Module - Language strings
|
||||
; MokoSuiteStoreLocator Search Module - Language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
@@ -11,3 +11,10 @@ MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_CITY="City"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_ALL_CITIES="All Cities"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS="Distance"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_ANY_DISTANCE="Any Distance"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location"
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Locating..."
|
||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_SET="Location Set"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
; MokoJoomStoreLocator Search Module - System language strings
|
||||
; MokoSuiteStoreLocator Search Module - System language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
+7
-7
@@ -5,17 +5,17 @@
|
||||
|
||||
=========================================================================
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoJoomStoreLocator
|
||||
INGROUP: mod_mokojoomstorelocator_search
|
||||
PATH: src/packages/mod_mokojoomstorelocator_search/mod_mokojoomstorelocator_search.xml
|
||||
DEFGROUP: MokoSuiteStoreLocator
|
||||
INGROUP: mod_mokosuitestorelocator_search
|
||||
PATH: src/packages/mod_mokosuitestorelocator_search/mod_mokosuitestorelocator_search.xml
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Module manifest for the store locator search module
|
||||
=========================================================================
|
||||
-->
|
||||
<extension type="module" client="site" method="upgrade">
|
||||
<name>mod_mokojoomstorelocator_search</name>
|
||||
<version>1.0.0</version>
|
||||
<creationDate>2026-05-21</creationDate>
|
||||
<name>mod_mokosuitestorelocator_search</name>
|
||||
<version>01.00.01</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
@@ -23,7 +23,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<description>MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC</description>
|
||||
|
||||
<namespace path="src">Moko\Module\MokoJoomStoreLocatorSearch</namespace>
|
||||
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorSearch</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage mod_mokosuitestorelocator_search
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoSuiteStoreLocatorSearch\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||
use Joomla\Database\DatabaseAwareInterface;
|
||||
use Joomla\Database\DatabaseAwareTrait;
|
||||
|
||||
/**
|
||||
* Dispatcher for mod_mokosuitestorelocator_search.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface, DatabaseAwareInterface
|
||||
{
|
||||
use HelperFactoryAwareTrait;
|
||||
use DatabaseAwareTrait;
|
||||
|
||||
/**
|
||||
* Returns the layout data.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
$params = $data['params'];
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Load distinct cities
|
||||
$query = $db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('city'))
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('city') . " != ''")
|
||||
->order($db->quoteName('city') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$data['cities'] = $db->loadColumn() ?: [];
|
||||
|
||||
// Load distinct states
|
||||
$query = $db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('state'))
|
||||
->from($db->quoteName('#__mokosuitestorelocator_locations'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('state') . " != ''")
|
||||
->order($db->quoteName('state') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$data['states'] = $db->loadColumn() ?: [];
|
||||
|
||||
// Build radius options from params
|
||||
$radiusStr = $params->get('radius_options', '5,10,25,50,100');
|
||||
$data['radiusOptions'] = array_map('intval', array_filter(explode(',', $radiusStr)));
|
||||
$data['radiusUnit'] = $params->get('radius_unit', 'miles');
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage mod_mokosuitestorelocator_search
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var array $displayData */
|
||||
$params = $displayData['params'];
|
||||
$cities = $displayData['cities'] ?? [];
|
||||
$states = $displayData['states'] ?? [];
|
||||
$radiusOptions = $displayData['radiusOptions'] ?? [];
|
||||
$radiusUnit = $displayData['radiusUnit'] ?? 'miles';
|
||||
$showCity = (int) $params->get('show_city_filter', 1);
|
||||
$showRadius = (int) $params->get('show_radius_filter', 1);
|
||||
$moduleId = $displayData['module']->id;
|
||||
?>
|
||||
<div class="mod-mokosuitestorelocator-search">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
|
||||
method="get" class="mokosuitestorelocator-search-form" id="mokosuitestorelocator-search-<?php echo (int) $moduleId; ?>">
|
||||
|
||||
<div class="mokosuitestorelocator-search-field">
|
||||
<label for="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>">
|
||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>"
|
||||
name="search"
|
||||
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<?php if ($showCity && !empty($cities)) : ?>
|
||||
<div class="mokosuitestorelocator-search-field">
|
||||
<label for="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>">
|
||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_CITY'); ?>
|
||||
</label>
|
||||
<select id="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>"
|
||||
name="city" class="form-select">
|
||||
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ALL_CITIES'); ?></option>
|
||||
<?php foreach ($cities as $city) : ?>
|
||||
<option value="<?php echo $this->escape($city); ?>">
|
||||
<?php echo $this->escape($city); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($showRadius && !empty($radiusOptions)) : ?>
|
||||
<div class="mokosuitestorelocator-search-field">
|
||||
<label for="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>">
|
||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS'); ?>
|
||||
</label>
|
||||
<select id="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>"
|
||||
name="radius" class="form-select">
|
||||
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ANY_DISTANCE'); ?></option>
|
||||
<?php foreach ($radiusOptions as $radius) : ?>
|
||||
<option value="<?php echo (int) $radius; ?>">
|
||||
<?php echo (int) $radius . ' ' . $this->escape($radiusUnit); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="lat" id="mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>" value="" />
|
||||
<input type="hidden" name="lng" id="mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>" value="" />
|
||||
<input type="hidden" name="radius_unit" value="<?php echo $this->escape($radiusUnit); ?>" />
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary mokosuitestorelocator-geolocation-btn"
|
||||
id="mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>">
|
||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||
</button>
|
||||
|
||||
<input type="hidden" name="option" value="com_mokosuitestorelocator" />
|
||||
<input type="hidden" name="view" value="locations" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if ($showRadius) : ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>');
|
||||
if (!btn || !navigator.geolocation) {
|
||||
if (btn) btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING', true); ?>';
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(pos) {
|
||||
document.getElementById('mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>').value = pos.coords.latitude;
|
||||
document.getElementById('mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>').value = pos.coords.longitude;
|
||||
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_SET', true); ?>';
|
||||
btn.disabled = false;
|
||||
},
|
||||
function() {
|
||||
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION', true); ?>';
|
||||
btn.disabled = false;
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
@@ -8,18 +8,18 @@
|
||||
|
||||
=========================================================================
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoJoomStoreLocator
|
||||
INGROUP: pkg_mokojoomstorelocator
|
||||
PATH: src/pkg_mokojoomstorelocator.xml
|
||||
DEFGROUP: MokoSuiteStoreLocator
|
||||
INGROUP: pkg_mokosuitestorelocator
|
||||
PATH: src/pkg_mokosuitestorelocator.xml
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Package manifest for the MokoJoomStoreLocator package
|
||||
BRIEF: Package manifest for the MokoSuiteStoreLocator package
|
||||
=========================================================================
|
||||
-->
|
||||
<extension type="package" method="upgrade">
|
||||
<name>pkg_mokojoomstorelocator</name>
|
||||
<packagename>mokojoomstorelocator</packagename>
|
||||
<version>1.0.0</version>
|
||||
<creationDate>2026-05-21</creationDate>
|
||||
<name>pkg_mokosuitestorelocator</name>
|
||||
<packagename>mokosuitestorelocator</packagename>
|
||||
<version>01.00.01</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
@@ -29,13 +29,13 @@
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
<files>
|
||||
<file type="component" id="com_mokojoomstorelocator">com_mokojoomstorelocator.zip</file>
|
||||
<file type="module" id="mod_mokojoomstorelocator_map" client="site">mod_mokojoomstorelocator_map.zip</file>
|
||||
<file type="module" id="mod_mokojoomstorelocator_search" client="site">mod_mokojoomstorelocator_search.zip</file>
|
||||
<file type="component" id="com_mokosuitestorelocator">com_mokosuitestorelocator.zip</file>
|
||||
<file type="module" id="mod_mokosuitestorelocator_map" client="site">mod_mokosuitestorelocator_map.zip</file>
|
||||
<file type="module" id="mod_mokosuitestorelocator_search" client="site">mod_mokosuitestorelocator_search.zip</file>
|
||||
</files>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoJoomStoreLocator Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/updates.xml</server>
|
||||
<server type="extension" name="MokoSuiteStoreLocator Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server>
|
||||
</updateservers>
|
||||
<dlid prefix="dlid=" suffix=""/>
|
||||
<blockChildUninstall>true</blockChildUninstall>
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteStoreLocator
|
||||
* @subpackage pkg_mokosuitestorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Installer\InstallerAdapter;
|
||||
use Joomla\CMS\Installer\InstallerScriptInterface;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Package installation script for MokoSuiteStoreLocator.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Pkg_MokosuitestorelocatorInstallerScript implements InstallerScriptInterface
|
||||
{
|
||||
/**
|
||||
* Minimum PHP version required.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $minimumPhp = '8.2';
|
||||
|
||||
/**
|
||||
* Minimum Joomla version required.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $minimumJoomla = '5.0.0';
|
||||
|
||||
/**
|
||||
* Called before any type of action.
|
||||
*
|
||||
* @param string $type Installation type (install, update, discover_install).
|
||||
* @param InstallerAdapter $parent The parent installer object.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function preflight(string $type, InstallerAdapter $parent): bool
|
||||
{
|
||||
if (version_compare(PHP_VERSION, $this->minimumPhp, '<'))
|
||||
{
|
||||
Log::add(
|
||||
'MokoSuiteStoreLocator requires PHP ' . $this->minimumPhp . ' or later.',
|
||||
Log::WARNING,
|
||||
'jerror'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (version_compare(JVERSION, $this->minimumJoomla, '<'))
|
||||
{
|
||||
Log::add(
|
||||
'MokoSuiteStoreLocator requires Joomla ' . $this->minimumJoomla . ' or later.',
|
||||
Log::WARNING,
|
||||
'jerror'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on installation.
|
||||
*
|
||||
* @param InstallerAdapter $parent The parent installer object.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function install(InstallerAdapter $parent): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on update.
|
||||
*
|
||||
* @param InstallerAdapter $parent The parent installer object.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function update(InstallerAdapter $parent): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on uninstallation.
|
||||
*
|
||||
* @param InstallerAdapter $parent The parent installer object.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function uninstall(InstallerAdapter $parent): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after any type of action.
|
||||
*
|
||||
* @param string $type Installation type.
|
||||
* @param InstallerAdapter $parent The parent installer object.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function postflight(string $type, InstallerAdapter $parent): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
; MokoJoomStoreLocator - Admin language strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GNU General Public License version 3 or later; see LICENSE
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
||||
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
|
||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
|
||||
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_CITY="City"
|
||||
COM_MOKOJOOMSTORELOCATOR_STATE="State"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
|
||||
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\MVCComponent;
|
||||
|
||||
/**
|
||||
* Component class for com_mokojoomstorelocator.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class MokoJoomStoreLocatorComponent extends MVCComponent
|
||||
{
|
||||
// TODO: Add boot(), getRouterRules(), or custom services as needed
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\AdminModel;
|
||||
use Joomla\CMS\Form\Form;
|
||||
use Joomla\CMS\Table\Table;
|
||||
|
||||
/**
|
||||
* Single location edit model.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationModel extends AdminModel
|
||||
{
|
||||
/**
|
||||
* The type alias for this content type.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public $typeAlias = 'com_mokojoomstorelocator.location';
|
||||
|
||||
/**
|
||||
* Get the form for this model.
|
||||
*
|
||||
* @param array $data Data for the form.
|
||||
* @param boolean $loadData True if the form is to load its own data.
|
||||
*
|
||||
* @return Form|boolean A Form object on success, false on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokojoomstorelocator.location',
|
||||
'location',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
if (empty($form))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the data for the form.
|
||||
*
|
||||
* @return mixed The data for the form.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function loadFormData()
|
||||
{
|
||||
$data = $this->getItem();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table for this model.
|
||||
*
|
||||
* @param string $name The table name.
|
||||
* @param string $prefix The table prefix.
|
||||
* @param array $options Configuration array for the table.
|
||||
*
|
||||
* @return Table
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
/**
|
||||
* Locations list model for the admin view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationsModel extends ListModel
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config An optional associative array of configuration settings.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields']))
|
||||
{
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'title', 'a.title',
|
||||
'city', 'a.city',
|
||||
'state', 'a.state',
|
||||
'published', 'a.published',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SQL query to load the list data.
|
||||
*
|
||||
* @return QueryInterface
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'));
|
||||
|
||||
// TODO: Add filter by published state
|
||||
// TODO: Add filter by category
|
||||
// TODO: Add search filter
|
||||
// TODO: Add ordering clause
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage com_mokojoomstorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Location table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class LocationTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database driver object.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokojoomstorelocator_locations', 'id', $db);
|
||||
|
||||
$this->setColumnAlias('published', 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded check method to ensure data integrity.
|
||||
*
|
||||
* @return boolean True if the data is valid.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
// TODO: Validate title is not empty
|
||||
// TODO: Auto-generate alias from title if empty
|
||||
// TODO: Validate latitude/longitude ranges
|
||||
// TODO: Set created/modified timestamps
|
||||
|
||||
return parent::check();
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage mod_mokojoomstorelocator_map
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoJoomStoreLocatorMap\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||
|
||||
/**
|
||||
* Dispatcher for mod_mokojoomstorelocator_map.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
|
||||
{
|
||||
use HelperFactoryAwareTrait;
|
||||
|
||||
/**
|
||||
* Returns the layout data.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
|
||||
// TODO: Load published locations with coordinates from the component table
|
||||
// TODO: Build marker data array for the map JS
|
||||
$data['locations'] = [];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage mod_mokojoomstorelocator_map
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/** @var array $displayData */
|
||||
$params = $displayData['params'];
|
||||
$locations = $displayData['locations'] ?? [];
|
||||
$mapHeight = $params->get('map_height', '400px');
|
||||
$mapZoom = (int) $params->get('map_zoom', 10);
|
||||
$provider = $params->get('map_provider', 'leaflet');
|
||||
?>
|
||||
<div class="mod-mokojoomstorelocator-map"
|
||||
id="mokojoomstorelocator-map-<?php echo $displayData['module']->id; ?>"
|
||||
style="height: <?php echo $this->escape($mapHeight); ?>;"
|
||||
data-locations="<?php echo $this->escape(json_encode($locations)); ?>"
|
||||
data-zoom="<?php echo $mapZoom; ?>"
|
||||
data-provider="<?php echo $this->escape($provider); ?>">
|
||||
<!-- TODO: Map renders here via JavaScript -->
|
||||
<noscript>
|
||||
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
|
||||
</noscript>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage mod_mokojoomstorelocator_search
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoJoomStoreLocatorSearch\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||
|
||||
/**
|
||||
* Dispatcher for mod_mokojoomstorelocator_search.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
|
||||
{
|
||||
use HelperFactoryAwareTrait;
|
||||
|
||||
/**
|
||||
* Returns the layout data.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
|
||||
// TODO: Load distinct cities/states for filter dropdowns
|
||||
// TODO: Build radius options array from params
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage mod_mokojoomstorelocator_search
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var array $displayData */
|
||||
$params = $displayData['params'];
|
||||
?>
|
||||
<div class="mod-mokojoomstorelocator-search">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
|
||||
method="get" class="mokojoomstorelocator-search-form">
|
||||
|
||||
<div class="mokojoomstorelocator-search-field">
|
||||
<label for="mokojoomstorelocator-query">
|
||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="mokojoomstorelocator-query"
|
||||
name="filter_search"
|
||||
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: City dropdown filter -->
|
||||
<!-- TODO: Radius dropdown filter -->
|
||||
<!-- TODO: Geolocation "Use my location" button -->
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||
</button>
|
||||
|
||||
<input type="hidden" name="option" value="com_mokojoomstorelocator" />
|
||||
<input type="hidden" name="view" value="locations" />
|
||||
</form>
|
||||
</div>
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomStoreLocator
|
||||
* @subpackage pkg_mokojoomstorelocator
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Installer\InstallerAdapter;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class Pkg_MokojoomstorelocatorInstallerScript
|
||||
{
|
||||
protected $minimumPhp = '8.1';
|
||||
protected $minimumJoomla = '4.4.0';
|
||||
|
||||
|
||||
|
||||
|
||||
public function preflight($type, $parent)
|
||||
{
|
||||
if (version_compare(PHP_VERSION, $this->minimumPhp, '<'))
|
||||
{
|
||||
Log::add('MokoJoomStoreLocator requires PHP ' . $this->minimumPhp . ' or later.', Log::WARNING, 'jerror');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (version_compare(JVERSION, $this->minimumJoomla, '<'))
|
||||
{
|
||||
Log::add('MokoJoomStoreLocator requires Joomla ' . $this->minimumJoomla . ' or later.', Log::WARNING, 'jerror');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->saveDownloadKey();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function postflight($type, $parent)
|
||||
{
|
||||
$this->restoreDownloadKey();
|
||||
$this->warnMissingLicenseKey();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function uninstall($parent)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
private ?string $savedDownloadKey = null;
|
||||
|
||||
private function saveDownloadKey(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('us.extra_query'))
|
||||
->from($db->quoteName('#__update_sites', 'us'))
|
||||
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id')
|
||||
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
|
||||
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokojoomstorelocator'))
|
||||
->setLimit(1)
|
||||
);
|
||||
$key = $db->loadResult();
|
||||
if (!empty($key)) { $this->savedDownloadKey = $key; }
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
private function restoreDownloadKey(): void
|
||||
{
|
||||
if ($this->savedDownloadKey === null) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('us.update_site_id'))
|
||||
->from($db->quoteName('#__update_sites', 'us'))
|
||||
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id')
|
||||
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
|
||||
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokojoomstorelocator'))
|
||||
->setLimit(1)
|
||||
);
|
||||
$siteId = (int) $db->loadResult();
|
||||
if ($siteId > 0)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__update_sites'))
|
||||
->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey))
|
||||
->where($db->quoteName('update_site_id') . ' = ' . $siteId)
|
||||
)->execute();
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
private function warnMissingLicenseKey(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
|
||||
->from($db->quoteName('#__update_sites'))
|
||||
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoJoomStoreLocator%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoJoomStoreLocator%') . ')')
|
||||
->setLimit(1)
|
||||
);
|
||||
$site = $db->loadObject();
|
||||
|
||||
if ($site)
|
||||
{
|
||||
$eq = (string) ($site->extra_query ?? '');
|
||||
if (!empty($eq) && strpos($eq, 'dlid=') !== false) { parse_str($eq, $p); if (!empty($p['dlid'])) { return; } }
|
||||
$editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id;
|
||||
}
|
||||
else
|
||||
{
|
||||
$editUrl = 'index.php?option=com_installer&view=updatesites';
|
||||
}
|
||||
|
||||
\Joomla\CMS\Factory::getApplication()->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Required</strong> — '
|
||||
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||
. '<a href="' . $editUrl . '" class="btn btn-sm btn-warning ms-2">Enter License Key</a>',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user