1 Commits

Author SHA1 Message Date
jmiller 57e106fff7 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-27 20:44:46 +00:00
51 changed files with 205 additions and 2182 deletions
+6
View File
@@ -13,6 +13,12 @@
name: "Generic: Project CI" name: "Generic: Project CI"
on: on:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch: workflow_dispatch:
permissions: permissions:
-331
View File
@@ -164,75 +164,6 @@ jobs:
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
fi 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 - name: Check language files referenced in manifest
run: | run: |
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
@@ -716,268 +647,6 @@ jobs:
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi 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: release-readiness:
name: Release Readiness Check name: Release Readiness Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 01.00.18 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+16
View File
@@ -93,8 +93,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
@@ -171,6 +183,7 @@ jobs:
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -181,6 +194,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -217,6 +231,7 @@ jobs:
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -230,6 +245,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
@@ -47,13 +47,6 @@ jobs:
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}" 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 - name: Clone mokocli
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+2 -31
View File
@@ -5,35 +5,7 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - Unreleased ## [1.1.0] - Unreleased
### Added
- Multi-category support with parent/child hierarchy (#1)
- Categories admin CRUD — list, edit, color picker, custom marker icon
- Location-category junction table (many-to-many)
- Categories tab on location edit form (multi-select)
- Category filtering on site frontend (`catid` parameter)
- Custom map markers per category — SVG/PNG icon support (#2)
- Map module JOINs category data for marker icons and colors
- `access.xml` with full Joomla ACL permissions (#30)
- SQL update schema with `sql/updates/mysql/` versioned files (#31)
- REST API via Web Services plugin (`plg_webservices_mokosuitestorelocator`) (#29)
- API controller + JSON:API view for locations CRUD at `/api/v1/mokosuitestorelocator/locations`
- `LocationBridgeHelper` — static helper for cross-extension integration (#48)
- `LocationSavedEvent` — fires `onStoreLocatorLocationSaved` for cache invalidation
- Plugin added to package manifest
### Changed
- Map module dispatcher uses aliased table queries with category JOIN
- ORDER BY clauses in admin and site models now validated against filter_fields allowlist
### Security
- CSV import: MIME type validation, 2 MB file size limit, delimiter allowlist (#34)
- CSV import: formula injection prevention (strips leading `=+\-@\t\r` characters)
- ORDER BY injection prevention — replaced `$db->escape()` with allowlist validation
- Map module: `$mapHeight` CSS value validated with regex pattern
## [1.1.0] - 2026-06-23
### Added ### Added
- Haversine proximity search — filter locations by distance from user's coordinates - Haversine proximity search — filter locations by distance from user's coordinates
@@ -46,10 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CSV import: auto-detect column headers (title/name/store, address/street, city, etc.) - 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: per-row validation via LocationTable::bind()->check()->store()
- CSV import view accessible from admin toolbar and submenu - CSV import view accessible from admin toolbar and submenu
- FocalPoint (Shack Locations) migration import
- Language strings for directions, geocoding feedback, and import UI - Language strings for directions, geocoding feedback, and import UI
## [01.00.00] - 2026-06-23 ## [1.0.0] - 2026-06-23
### Added ### Added
- Admin `LocationController` (FormController) for single-record save/cancel/apply - Admin `LocationController` (FormController) for single-record save/cancel/apply
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: DEFGROUP:
INGROUP: Project.Documentation INGROUP: Project.Documentation
REPO: REPO:
VERSION: 01.00.18 VERSION: 04.04.01
PATH: ./CODE_OF_CONDUCT.md PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
--> -->
+6 -14
View File
@@ -9,7 +9,6 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
| Store Locator Component | component | `com_mokosuitestorelocator` | | Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` | | Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` | | Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Web Services API | plugin (webservices) | `plg_webservices_mokosuitestorelocator` |
## Requirements ## Requirements
@@ -28,9 +27,7 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
### Implemented ### Implemented
- **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image) - **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 - **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete
- **Multi-category** — categories with parent/child hierarchy, color, custom marker icons, many-to-many assignments - **Site frontend** — locations list and detail views with pagination
- **Custom map markers** — per-category SVG/PNG marker icons on the Leaflet map
- **Site frontend** — locations list and detail views with pagination and category filtering
- **Schema.org** — LocalBusiness structured data markup on all frontend templates - **Schema.org** — LocalBusiness structured data markup on all frontend templates
- **SEF URLs** — router with menu, standard, and nomenu rules - **SEF URLs** — router with menu, standard, and nomenu rules
- **Menu items** — "All Locations" list and single "Location Detail" picker - **Menu items** — "All Locations" list and single "Location Detail" picker
@@ -40,18 +37,13 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
- **Get Directions** — Google Maps directions link on detail page and map popups - **Get Directions** — Google Maps directions link on detail page and map popups
- **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM) - **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM)
- **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping - **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping
- **FocalPoint migration** — one-click import from Shack Locations / FocalPoint
- **REST API** — JSON:API endpoints via Joomla Web Services plugin
- **ACL permissions** — `access.xml` with standard Joomla permission actions
- **SQL update schema** — versioned migration files for safe upgrades
- **Shop integration** — `LocationBridgeHelper` for cross-extension data access, `LocationSavedEvent` for cache invalidation
- **Security hardening** — CSV injection prevention, MIME validation, ORDER BY allowlists, input sanitization
### Planned ### Planned
- Marker clustering for dense location areas (Leaflet.markercluster) - Marker clustering for dense location areas
- Google Maps provider as alternative to Leaflet - Multi-category support with custom map markers
- CSV export - ACL permissions and SQL upgrade schema
- Photo gallery per location - REST API via Joomla Web Services plugin
- MokoSuiteShop integration for multi-store ecommerce
## Development ## Development
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL] REPO: [REPOSITORY_URL]
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 01.00.18 VERSION: 04.04.01
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitestorelocator">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
</section>
</access>
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Category edit form -->
<form>
<fieldset name="details">
<field
name="id"
type="hidden"
/>
<field
name="title"
type="text"
label="JGLOBAL_TITLE"
required="true"
size="40"
/>
<field
name="alias"
type="text"
label="JFIELD_ALIAS_LABEL"
size="40"
hint="JFIELD_ALIAS_PLACEHOLDER"
/>
<field
name="parent_id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_PARENT"
default="0"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
>
<option value="0">COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT</option>
</field>
<field
name="description"
type="editor"
label="JGLOBAL_DESCRIPTION"
filter="safehtml"
buttons="true"
/>
<field
name="published"
type="list"
label="JSTATUS"
default="1"
>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
</fieldset>
<fieldset name="appearance" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE">
<field
name="color"
type="color"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR"
default=""
/>
<field
name="marker_icon"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON"
/>
</fieldset>
</form>
@@ -1,47 +0,0 @@
<?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"
/>
<field
name="published"
type="list"
label="JOPTION_SELECT_PUBLISHED"
onchange="this.form.submit();"
>
<option value="">JOPTION_SELECT_PUBLISHED</option>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
</fields>
<fields name="list">
<field
name="fullordering"
type="list"
label="JGLOBAL_SORT_BY"
default="a.ordering 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.ordering ASC">JGRID_HEADING_ORDERING_ASC</option>
<option value="a.ordering DESC">JGRID_HEADING_ORDERING_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>
@@ -43,16 +43,6 @@
</field> </field>
</fieldset> </fieldset>
<fieldset name="categories" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES">
<field
name="categories"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORIES"
multiple="true"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
/>
</fieldset>
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS"> <fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
<field <field
name="address" name="address"
@@ -59,22 +59,3 @@ 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_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_NO_ROWS="The CSV file contains no data rows."
COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required." COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE="Import from FocalPoint"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC="Migrate locations from an installed FocalPoint (Shack Locations) component. Coordinates, custom fields (email, website, hours), and metadata are mapped automatically."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON="Import FocalPoint Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS="%d location(s) imported from FocalPoint."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE="The uploaded file exceeds the 2 MB size limit."
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_PARENT="Parent Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT="- No Parent -"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR="Color"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON="Custom Marker Icon"
COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION="Store Location Categories"
COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED="A category title is required."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance"
@@ -38,38 +38,3 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
KEY `idx_alias` (`alias`(191)), KEY `idx_alias` (`alias`(191)),
KEY `idx_coordinates` (`latitude`, `longitude`) KEY `idx_coordinates` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Categories table
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`description` text NOT NULL,
`color` varchar(7) NOT NULL DEFAULT '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Location-Category junction table (many-to-many)
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -3,6 +3,4 @@
-- SPDX-License-Identifier: GPL-3.0-or-later -- SPDX-License-Identifier: GPL-3.0-or-later
-- ========================================================================= -- =========================================================================
DROP TABLE IF EXISTS `#__mokosuitestorelocator_location_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`; DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`;
@@ -1 +0,0 @@
-- MokoSuiteStoreLocator 01.00.00 — Initial release, no schema changes needed.
@@ -1,28 +0,0 @@
-- MokoSuiteStoreLocator 01.00.01 — Add categories and location-category junction tables.
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`description` text NOT NULL,
`color` varchar(7) NOT NULL DEFAULT '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -1,37 +0,0 @@
<?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;
/**
* Categories list controller.
*
* @since 1.2.0
*/
class CategoriesController extends AdminController
{
/**
* Get the model for this controller.
*
* @param string $name Model name.
* @param string $prefix Model prefix.
* @param array $config Configuration.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
* @since 1.2.0
*/
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -1,22 +0,0 @@
<?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;
/**
* Category edit controller.
*
* @since 1.2.0
*/
class CategoryController extends FormController
{
}
@@ -49,12 +49,6 @@ class ImportController extends BaseController
$file = $this->input->files->get('jform', [], 'array'); $file = $this->input->files->get('jform', [], 'array');
$delimiter = $this->input->post->getString('delimiter', ','); $delimiter = $this->input->post->getString('delimiter', ',');
// Validate delimiter against allowlist
if (!\in_array($delimiter, [',', ';', '|', "\t"], true))
{
$delimiter = ',';
}
$csvFile = $file['csv_file'] ?? null; $csvFile = $file['csv_file'] ?? null;
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name'])) if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
@@ -65,15 +59,6 @@ class ImportController extends BaseController
return; return;
} }
// Enforce 2 MB file size limit
if ($csvFile['size'] > 2 * 1024 * 1024)
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
// Validate file extension // Validate file extension
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
@@ -85,19 +70,6 @@ class ImportController extends BaseController
return; return;
} }
// Validate MIME type
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($csvFile['tmp_name']);
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel', 'application/octet-stream'];
if (!$mime || !\in_array($mime, $allowedMimes, true))
{
$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); $result = $model->processImport($csvFile['tmp_name'], $delimiter);
if ($result['imported'] > 0) if ($result['imported'] > 0)
@@ -112,45 +84,4 @@ class ImportController extends BaseController
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false)); $this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
} }
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* @return void
*
* @since 1.1.0
*/
public function focalpoint(): void
{
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
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');
$result = $model->importFromFocalPoint();
if ($result['imported'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS', $result['imported']));
}
if ($result['skipped'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning');
}
foreach ($result['errors'] as $error)
{
$this->setMessage($error, 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
}
} }
@@ -1,46 +0,0 @@
<?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\Event;
defined('_JEXEC') or die;
use Joomla\CMS\Event\AbstractEvent;
/**
* Event fired after a location is saved, for cross-extension integration.
*
* @since 1.2.0
*/
class LocationSavedEvent extends AbstractEvent
{
/**
* Constructor.
*
* @param string $name The event name.
* @param array $arguments Event arguments: ['locationData' => array].
*
* @since 1.2.0
*/
public function __construct(string $name, array $arguments = [])
{
parent::__construct($name, $arguments);
}
/**
* Get the saved location data.
*
* @return array
*
* @since 1.2.0
*/
public function getLocationData(): array
{
return $this->getArgument('locationData', []);
}
}
@@ -1,162 +0,0 @@
<?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\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\ParameterType;
/**
* Bridge helper for external extensions (e.g. MokoSuiteShop) to query location data.
*
* All methods are static and return plain objects/arrays with no Joomla model dependencies.
*
* @since 1.2.0
*/
class LocationBridgeHelper
{
/**
* Get all active locations.
*
* @param bool $publishedOnly Only return published locations.
*
* @return array
*
* @since 1.2.0
*/
public static function getLocations(bool $publishedOnly = true): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'));
if ($publishedOnly)
{
$query->where($db->quoteName('published') . ' = 1');
}
$query->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single location by ID.
*
* @param int $locationId The location ID.
*
* @return object|null
*
* @since 1.2.0
*/
public static function getById(int $locationId): ?object
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $locationId, ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadObject() ?: null;
}
/**
* Get locations within a radius using Haversine formula.
*
* @param float $lat Latitude of the search origin.
* @param float $lng Longitude of the search origin.
* @param float $radiusMiles Search radius in miles.
* @param int $limit Maximum results.
*
* @return array Objects with an additional `distance` property (miles).
*
* @since 1.2.0
*/
public static function getNearby(float $lat, float $lng, float $radiusMiles = 25, int $limit = 10): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$haversine = '(3959 * ACOS(LEAST(1, GREATEST(-1, '
. 'SIN(RADIANS(' . $db->quoteName('latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) '
. '+ COS(RADIANS(' . $db->quoteName('latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) '
. '* COS(RADIANS(' . $db->quoteName('longitude') . ' - ' . (float) $lng . '))'
. '))))';
$query->select('*')
->select($haversine . ' AS distance')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('longitude') . ' IS NOT NULL')
->where($haversine . ' <= ' . (float) $radiusMiles)
->order('distance ASC');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by city.
*
* @param string $city City name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByCity(string $city): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('city') . ' = :city')
->bind(':city', $city)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by state/province.
*
* @param string $state State/province name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByState(string $state): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('state') . ' = :state')
->bind(':state', $state)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -1,126 +0,0 @@
<?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;
/**
* Categories list model.
*
* @since 1.2.0
*/
class CategoriesModel extends ListModel
{
/**
* Constructor.
*
* @param array $config Configuration settings.
*
* @since 1.2.0
*/
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'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.2.0
*/
protected function populateState($ordering = 'a.ordering', $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.2.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_categories', 'a'));
// Count locations per category
$subQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc'))
->where($db->quoteName('lc.category_id') . ' = ' . $db->quoteName('a.id'));
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('location_count'));
// 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')
->bind(':search', $search);
}
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
}
@@ -1,85 +0,0 @@
<?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\Form\Form;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
/**
* Single category edit model.
*
* @since 1.2.0
*/
class CategoryModel extends AdminModel
{
/**
* The type alias for this content type.
*
* @var string
* @since 1.2.0
*/
public $typeAlias = 'com_mokosuitestorelocator.category';
/**
* 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
*
* @since 1.2.0
*/
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokosuitestorelocator.category',
'category',
['control' => 'jform', 'load_data' => $loadData]
);
if (empty($form))
{
return false;
}
return $form;
}
/**
* Load the data for the form.
*
* @return mixed
*
* @since 1.2.0
*/
protected function loadFormData()
{
return $this->getItem();
}
/**
* Get the table for this model.
*
* @param string $name The table name.
* @param string $prefix The table prefix.
* @param array $options Configuration array.
*
* @return Table
*
* @since 1.2.0
*/
public function getTable($name = 'Category', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
}
@@ -206,192 +206,9 @@ class ImportModel extends BaseDatabaseModel
foreach ($mapping as $dbField => $csvIndex) foreach ($mapping as $dbField => $csvIndex)
{ {
$value = trim($row[$csvIndex] ?? ''); $data[$dbField] = trim($row[$csvIndex] ?? '');
$data[$dbField] = $this->sanitizeCsvValue($value);
} }
return $data; return $data;
} }
/**
* Sanitize a CSV value to prevent formula injection.
*
* Strips leading characters that spreadsheet applications interpret as formulas.
*
* @param string $value Raw CSV cell value.
*
* @return string Sanitized value.
*
* @since 1.1.0
*/
private function sanitizeCsvValue(string $value): string
{
if ($value === '')
{
return $value;
}
// Strip leading formula trigger characters
return ltrim($value, "=+\-@\t\r");
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* Reads directly from #__focalpoint_locations and #__focalpoint_locationtypes
* tables and inserts into #__mokosuitestorelocator_locations using the standard
* bind()->check()->store() flow.
*
* @return array ['imported' => int, 'skipped' => int, 'errors' => string[]]
*
* @since 1.1.0
*/
public function importFromFocalPoint(): array
{
$result = ['imported' => 0, 'skipped' => 0, 'errors' => []];
$db = $this->getDatabase();
// Check if FocalPoint tables exist
$tables = $db->getTableList();
$prefix = $db->getPrefix();
$fpTable = $prefix . 'focalpoint_locations';
if (!\in_array($fpTable, $tables))
{
$result['errors'][] = 'FocalPoint is not installed — table #__focalpoint_locations not found.';
return $result;
}
// Load all FocalPoint locations
$query = $db->getQuery(true)
->select('a.*')
->from($db->quoteName('#__focalpoint_locations', 'a'))
->order($db->quoteName('a.id') . ' ASC');
$db->setQuery($query);
$fpLocations = $db->loadObjectList();
if (empty($fpLocations))
{
$result['errors'][] = 'No locations found in FocalPoint.';
return $result;
}
// Load location type names for category context
$typeQuery = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__focalpoint_locationtypes'));
$db->setQuery($typeQuery);
$typeNames = $db->loadObjectList('id');
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Table\LocationTable $table */
$table = $this->getMVCFactory()->createTable('Location', 'Administrator');
foreach ($fpLocations as $fpLoc)
{
$table->reset();
$table->id = 0;
// Parse custom fields JSON for email, website, phone, hours
$customData = $this->parseFocalPointCustomFields($fpLoc->customfieldsdata ?? '');
// Map FocalPoint fields to our schema
$data = [
'title' => $fpLoc->title,
'alias' => $fpLoc->alias ?: '',
'description' => trim(($fpLoc->description ?? '') . "\n" . ($fpLoc->fulldescription ?? '')),
'address' => $fpLoc->address ?? '',
'city' => $customData['city'] ?? '',
'state' => $customData['state'] ?? '',
'postcode' => $customData['postcode'] ?? $customData['zip'] ?? '',
'country' => $customData['country'] ?? '',
'latitude' => $fpLoc->latitude != 0 ? $fpLoc->latitude : null,
'longitude' => $fpLoc->longitude != 0 ? $fpLoc->longitude : null,
'phone' => $customData['phone'] ?? $fpLoc->phone ?? '',
'email' => $customData['email'] ?? '',
'website' => $customData['website'] ?? $customData['url'] ?? '',
'hours' => $customData['hours'] ?? $customData['business_hours'] ?? '',
'image' => $fpLoc->image ?? '',
'published' => (int) ($fpLoc->state ?? 0),
'ordering' => (int) ($fpLoc->ordering ?? 0),
'params' => '{}',
];
if (!$table->bind($data))
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->check())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->store())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
$result['imported']++;
}
return $result;
}
/**
* Parse FocalPoint customfieldsdata JSON into a flat key-value array.
*
* FocalPoint stores custom field data as JSON. The structure varies by version:
* - Simple: {"fieldname": "value", ...}
* - Nested: {"fieldname": {"value": "...", "label": "..."}, ...}
*
* We normalize to lowercase keys with string values for easy field matching.
*
* @param string $json The customfieldsdata JSON string.
*
* @return array Flat associative array of field_name => value.
*
* @since 1.1.0
*/
private function parseFocalPointCustomFields(string $json): array
{
if (empty($json) || $json === '{}')
{
return [];
}
$decoded = json_decode($json, true);
if (!\is_array($decoded))
{
return [];
}
$fields = [];
foreach ($decoded as $key => $value)
{
$normalizedKey = strtolower(trim(str_replace([' ', '-'], '_', $key)));
if (\is_array($value))
{
// Nested format: {"value": "...", "label": "..."}
$fields[$normalizedKey] = trim((string) ($value['value'] ?? $value[0] ?? ''));
}
else
{
$fields[$normalizedKey] = trim((string) $value);
}
}
return $fields;
}
} }
@@ -13,7 +13,6 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form; use Joomla\CMS\Form\Form;
use Joomla\CMS\Http\HttpFactory; use Joomla\CMS\Http\HttpFactory;
use Moko\Component\MokoSuiteStoreLocator\Administrator\Event\LocationSavedEvent;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table; use Joomla\CMS\Table\Table;
@@ -59,6 +58,20 @@ class LocationModel extends AdminModel
return $form; 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. * Get the table for this model.
* *
@@ -105,99 +118,7 @@ class LocationModel extends AdminModel
} }
} }
// Extract categories before parent::save (it won't know about junction table) return parent::save($data);
$categories = $data['categories'] ?? [];
unset($data['categories']);
if (!parent::save($data))
{
return false;
}
// Save category associations
$locationId = (int) $this->getState($this->getName() . '.id');
$this->saveCategories($locationId, $categories);
// Fire event for cross-extension integration (e.g. MokoSuiteShop)
$data['id'] = $locationId;
Factory::getApplication()->getDispatcher()->dispatch(
'onStoreLocatorLocationSaved',
new LocationSavedEvent('onStoreLocatorLocationSaved', ['locationData' => $data])
);
return true;
}
/**
* Save location-category associations in the junction table.
*
* @param int $locationId The location ID.
* @param array $categories Array of category IDs.
*
* @return void
*
* @since 1.2.0
*/
private function saveCategories(int $locationId, array $categories): void
{
$db = $this->getDatabase();
// Remove existing associations
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :locationId')
->bind(':locationId', $locationId, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
// Insert new associations
if (!empty($categories))
{
$query = $db->getQuery(true)
->insert($db->quoteName('#__mokosuitestorelocator_location_categories'))
->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]);
foreach ($categories as $catId)
{
$catId = (int) $catId;
if ($catId > 0)
{
$query->values($locationId . ', ' . $catId);
}
}
$db->setQuery($query);
$db->execute();
}
}
/**
* Load the data for the form, including category associations.
*
* @return mixed
*
* @since 1.0.0
*/
protected function loadFormData()
{
$data = $this->getItem();
if ($data && (int) $data->id > 0)
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('category_id'))
->from($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :id')
->bind(':id', $data->id, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$data->categories = $db->loadColumn();
}
return $data;
} }
/** /**
@@ -111,17 +111,10 @@ class LocationsModel extends ListModel
->bind(':search4', $search); ->bind(':search4', $search);
} }
// Ordering — validate against filter_fields allowlist // Ordering
$orderCol = $this->state->get('list.ordering', 'a.title'); $orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC'); $orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.title';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query; return $query;
} }
@@ -1,81 +0,0 @@
<?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;
/**
* Category table class.
*
* @since 1.2.0
*/
class CategoryTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver object.
*
* @since 1.2.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokosuitestorelocator_categories', 'id', $db);
}
/**
* Overloaded check method to ensure data integrity.
*
* @return boolean True if the data is valid.
*
* @since 1.2.0
*/
public function check(): bool
{
if (trim($this->title) === '')
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED'));
return false;
}
if (trim($this->alias) === '')
{
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
// Validate color format
if ($this->color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color))
{
$this->color = '';
}
$now = Factory::getDate()->toSql();
if (!(int) $this->id)
{
if (!$this->created || $this->created === '0000-00-00 00:00:00')
{
$this->created = $now;
}
}
$this->modified = $now;
return parent::check();
}
}
@@ -1,51 +0,0 @@
<?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\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Categories list view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public $filterForm;
public $activeFilters;
public function display($tpl = null): void
{
$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();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES'), 'folder');
ToolbarHelper::addNew('category.add');
ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE');
}
}
@@ -1,54 +0,0 @@
<?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\Category;
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;
/**
* Category edit view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $form;
protected $item;
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
Factory::getApplication()->input->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title(
Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_' . ($isNew ? 'NEW' : 'EDIT')),
'folder'
);
ToolbarHelper::apply('category.apply');
ToolbarHelper::save('category.save');
ToolbarHelper::save2new('category.save2new');
ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -1,102 +0,0 @@
<?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\Categories\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=categories'); ?>"
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>
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
</div>
<?php else : ?>
<table class="table" id="categoryList">
<caption class="visually-hidden">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION'); ?>
</caption>
<thead>
<tr>
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.checkall'); ?>
</td>
<th scope="col">
<?php echo Text::_('JGLOBAL_TITLE'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR'); ?>
</th>
<th scope="col" class="w-10 text-center d-none d-md-table-cell">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JSTATUS'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JGRID_HEADING_ID'); ?>
</th>
</tr>
</thead>
<tbody>
<?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">
<?php if ((int) $item->level > 1) : ?>
<?php echo str_repeat('<span class="gi">&mdash;</span>', (int) $item->level - 1); ?>
<?php endif; ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=category.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</th>
<td class="text-center">
<?php if ($item->color) : ?>
<span style="display:inline-block;width:20px;height:20px;border-radius:3px;background-color:<?php echo $this->escape($item->color); ?>;"></span>
<?php else : ?>
&mdash;
<?php endif; ?>
</td>
<td class="text-center d-none d-md-table-cell">
<?php echo (int) $item->location_count; ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'categories.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</div>
</div>
</form>
@@ -1,52 +0,0 @@
<?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;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Category\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('parent_id'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('color'); ?>
<?php echo $this->form->renderField('marker_icon'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -60,21 +60,6 @@ use Joomla\CMS\Session\Session;
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<h4><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE'); ?></h4>
<p class="text-muted"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC'); ?></p>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=import.focalpoint'); ?>"
method="post">
<button type="submit" class="btn btn-outline-primary w-100">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON'); ?>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h4><?php echo Text::_('JHELP'); ?></h4> <h4><?php echo Text::_('JHELP'); ?></h4>
@@ -37,14 +37,6 @@ HTMLHelper::_('behavior.keepalive');
</div> </div>
<?php echo HTMLHelper::_('uitab.endTab'); ?> <?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'categories', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('categories'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?> <?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
@@ -1,37 +0,0 @@
<?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\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\ApiController;
/**
* REST API controller for locations.
*
* @since 1.2.0
*/
class LocationsController extends ApiController
{
/**
* The content type.
*
* @var string
* @since 1.2.0
*/
protected $contentType = 'locations';
/**
* The default view.
*
* @var string
* @since 1.2.0
*/
protected $default_view = 'locations';
}
@@ -1,68 +0,0 @@
<?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\Api\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
/**
* JSON:API view for locations.
*
* @since 1.2.0
*/
class JsonapiView extends BaseApiView
{
/**
* Fields to render for a single item.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderItem = [
'id',
'title',
'alias',
'description',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'email',
'website',
'hours',
'image',
'published',
];
/**
* Fields to render for a list.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderList = [
'id',
'title',
'alias',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'published',
];
}
@@ -15,7 +15,7 @@
--> -->
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>com_mokosuitestorelocator</name> <name>com_mokosuitestorelocator</name>
<version>01.00.18</version> <version>1.0.0</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -38,27 +38,14 @@
</sql> </sql>
</uninstall> </uninstall>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site"> <files folder="site">
<folder>language</folder> <folder>language</folder>
<folder>src</folder> <folder>src</folder>
<folder>tmpl</folder> <folder>tmpl</folder>
</files> </files>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<administration> <administration>
<files folder="admin"> <files folder="admin">
<filename>access.xml</filename>
<folder>forms</folder> <folder>forms</folder>
<folder>language</folder> <folder>language</folder>
<folder>services</folder> <folder>services</folder>
@@ -70,7 +57,6 @@
<menu>COM_MOKOJOOMSTORELOCATOR</menu> <menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu> <submenu>
<menu link="option=com_mokosuitestorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu> <menu link="option=com_mokosuitestorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu> <menu link="option=com_mokosuitestorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu> </submenu>
</administration> </administration>
@@ -99,9 +99,6 @@ class LocationsModel extends ListModel
$this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles'); $this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles');
$catid = $app->input->getInt('catid', 0);
$this->setState('filter.catid', $catid);
parent::populateState($ordering, $direction); parent::populateState($ordering, $direction);
} }
@@ -157,17 +154,6 @@ class LocationsModel extends ListModel
->bind(':state', $state); ->bind(':state', $state);
} }
// Category filter
$catid = (int) $this->getState('filter.catid');
if ($catid > 0)
{
$query->join('INNER', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')
. ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->where($db->quoteName('lc.category_id') . ' = :catid')
->bind(':catid', $catid, ParameterType::INTEGER);
}
// Proximity / Haversine distance filter // Proximity / Haversine distance filter
$lat = $this->getState('filter.lat'); $lat = $this->getState('filter.lat');
$lng = $this->getState('filter.lng'); $lng = $this->getState('filter.lng');
@@ -193,17 +179,10 @@ class LocationsModel extends ListModel
} }
else else
{ {
// Default ordering — validate against filter_fields allowlist // Default ordering
$orderCol = $this->state->get('list.ordering', 'a.ordering'); $orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC'); $orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
} }
return $query; return $query;
@@ -14,7 +14,7 @@
--> -->
<extension type="module" client="site" method="upgrade"> <extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_map</name> <name>mod_mokosuitestorelocator_map</name>
<version>01.00.18</version> <version>1.0.0</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -26,7 +26,6 @@
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorMap</namespace> <namespace path="src">Moko\Module\MokoSuiteStoreLocatorMap</namespace>
<files> <files>
<folder module="mod_mokosuitestorelocator_map">services</folder>
<folder>src</folder> <folder>src</folder>
<folder>tmpl</folder> <folder>tmpl</folder>
<folder>language</folder> <folder>language</folder>
@@ -1,25 +0,0 @@
<?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\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoSuiteStoreLocatorMap'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoSuiteStoreLocatorMap\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -41,29 +41,20 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$query = $db->getQuery(true); $query = $db->getQuery(true);
$query->select([ $query->select([
$db->quoteName('a.id'), $db->quoteName('id'),
$db->quoteName('a.title'), $db->quoteName('title'),
$db->quoteName('a.address'), $db->quoteName('address'),
$db->quoteName('a.city'), $db->quoteName('city'),
$db->quoteName('a.state'), $db->quoteName('state'),
$db->quoteName('a.postcode'), $db->quoteName('postcode'),
$db->quoteName('a.phone'), $db->quoteName('phone'),
$db->quoteName('a.latitude'), $db->quoteName('latitude'),
$db->quoteName('a.longitude'), $db->quoteName('longitude'),
]) ])
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a')) ->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('a.published') . ' = 1') ->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('a.latitude') . ' IS NOT NULL') ->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('a.longitude') . ' IS NOT NULL'); ->where($db->quoteName('longitude') . ' IS NOT NULL');
// Join to get primary category marker icon
$query->select([$db->quoteName('c.marker_icon'), $db->quoteName('c.color', 'cat_color')])
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')
. ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_categories', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('lc.category_id')
. ' AND ' . $db->quoteName('c.published') . ' = 1')
->group($db->quoteName('a.id'));
$db->setQuery($query); $db->setQuery($query);
$locations = $db->loadObjectList() ?: []; $locations = $db->loadObjectList() ?: [];
@@ -72,7 +63,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
foreach ($locations as $loc) foreach ($locations as $loc)
{ {
$marker = [ $markers[] = [
'id' => (int) $loc->id, 'id' => (int) $loc->id,
'title' => $loc->title, 'title' => $loc->title,
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '), 'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
@@ -80,18 +71,6 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
'lat' => (float) $loc->latitude, 'lat' => (float) $loc->latitude,
'lng' => (float) $loc->longitude, 'lng' => (float) $loc->longitude,
]; ];
if (!empty($loc->marker_icon))
{
$marker['marker_icon'] = $loc->marker_icon;
}
if (!empty($loc->cat_color))
{
$marker['cat_color'] = $loc->cat_color;
}
$markers[] = $marker;
} }
$data['locations'] = $markers; $data['locations'] = $markers;
@@ -15,11 +15,6 @@ $params = $displayData['params'];
$locations = $displayData['locations'] ?? []; $locations = $displayData['locations'] ?? [];
$moduleId = $displayData['module']->id; $moduleId = $displayData['module']->id;
$mapHeight = $params->get('map_height', '400px'); $mapHeight = $params->get('map_height', '400px');
if (!preg_match('/^\d+(px|em|rem|vh|%)$/', $mapHeight))
{
$mapHeight = '400px';
}
$mapZoom = (int) $params->get('map_zoom', 10); $mapZoom = (int) $params->get('map_zoom', 10);
$provider = $params->get('map_provider', 'leaflet'); $provider = $params->get('map_provider', 'leaflet');
$apiKey = $params->get('api_key', ''); $apiKey = $params->get('api_key', '');
@@ -74,16 +69,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
locations.forEach(function(loc) { locations.forEach(function(loc) {
var markerOptions = {}; var marker = L.marker([loc.lat, loc.lng]).addTo(map);
if (loc.marker_icon) {
markerOptions.icon = L.icon({
iconUrl: loc.marker_icon,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
}
var marker = L.marker([loc.lat, loc.lng], markerOptions).addTo(map);
var popup = '<strong>' + esc(loc.title) + '</strong>'; var popup = '<strong>' + esc(loc.title) + '</strong>';
if (loc.address) popup += '<br>' + esc(loc.address); if (loc.address) popup += '<br>' + esc(loc.address);
if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>'; if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>';
@@ -14,7 +14,7 @@
--> -->
<extension type="module" client="site" method="upgrade"> <extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_search</name> <name>mod_mokosuitestorelocator_search</name>
<version>01.00.18</version> <version>1.0.0</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -26,7 +26,6 @@
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorSearch</namespace> <namespace path="src">Moko\Module\MokoSuiteStoreLocatorSearch</namespace>
<files> <files>
<folder module="mod_mokosuitestorelocator_search">services</folder>
<folder>src</folder> <folder>src</folder>
<folder>tmpl</folder> <folder>tmpl</folder>
<folder>language</folder> <folder>language</folder>
@@ -1,25 +0,0 @@
<?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\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoSuiteStoreLocatorSearch'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoSuiteStoreLocatorSearch\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -1,2 +0,0 @@
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuiteStoreLocator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- =========================================================================
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
========================================================================= -->
<extension type="plugin" group="webservices" method="upgrade">
<name>plg_webservices_mokosuitestorelocator</name>
<version>01.00.18</version>
<creationDate>2026-06-24</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteStoreLocator</namespace>
<files>
<folder plugin="mokosuitestorelocator">services</folder>
<folder>src</folder>
<folder>language</folder>
</files>
</extension>
@@ -1,37 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage plg_webservices_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\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\WebServices\MokoSuiteStoreLocator\Extension\MokoSuiteStoreLocator;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new MokoSuiteStoreLocator(
$dispatcher,
(array) PluginHelper::getPlugin('webservices', 'mokosuitestorelocator')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -1,57 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage plg_webservices_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\WebServices\MokoSuiteStoreLocator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Event\SubscriberInterface;
/**
* Web Services plugin for MokoSuiteStoreLocator REST API.
*
* @since 1.2.0
*/
final class MokoSuiteStoreLocator extends CMSPlugin implements SubscriberInterface
{
/**
* Returns the subscribed events.
*
* @return array
*
* @since 1.2.0
*/
public static function getSubscribedEvents(): array
{
return [
'onBeforeApiRoute' => 'onBeforeApiRoute',
];
}
/**
* Register API routes.
*
* @param \Joomla\CMS\Event\ApiRouterEvent $event The event.
*
* @return void
*
* @since 1.2.0
*/
public function onBeforeApiRoute($event): void
{
$router = $event->getArgument('router') ?? $event->getRouter();
$router->createCRUDRoutes(
'v1/mokosuitestorelocator/locations',
'locations',
['component' => 'com_mokosuitestorelocator']
);
}
}
+4 -5
View File
@@ -18,26 +18,25 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>pkg_mokosuitestorelocator</name> <name>pkg_mokosuitestorelocator</name>
<packagename>mokosuitestorelocator</packagename> <packagename>mokosuitestorelocator</packagename>
<version>01.00.18</version> <version>1.0.0</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright> <copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license> <license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoSuiteStoreLocator — store locator component with interactive map and search modules for Joomla 5/6.</description> <description>PKG_MOKOJOOMSTORELOCATOR_DESC</description>
<dlid prefix="dlid=" suffix=""/>
<scriptfile>script.php</scriptfile> <scriptfile>script.php</scriptfile>
<files> <files>
<file type="component" id="com_mokosuitestorelocator">com_mokosuitestorelocator.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_map" client="site">mod_mokosuitestorelocator_map.zip</file>
<file type="module" id="mod_mokosuitestorelocator_search" client="site">mod_mokosuitestorelocator_search.zip</file> <file type="module" id="mod_mokosuitestorelocator_search" client="site">mod_mokosuitestorelocator_search.zip</file>
<file type="plugin" id="mokosuitestorelocator" group="webservices">plg_webservices_mokosuitestorelocator.zip</file>
</files> </files>
<updateservers> <updateservers>
<server type="extension" priority="1" name="Package - MokoSuiteStoreLocator">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server> <server type="extension" name="MokoSuiteStoreLocator Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server>
</updateservers> </updateservers>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall> <blockChildUninstall>true</blockChildUninstall>
</extension> </extension>