Merge pull request 'v1.2.0 — Categories, API, hardening, polish' (#62) from dev into main

This commit was merged in pull request #62.
This commit is contained in:
2026-06-28 20:06:07 +00:00
62 changed files with 2568 additions and 273 deletions
+331
View File
@@ -164,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
@@ -647,6 +716,268 @@ jobs:
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
-126
View File
@@ -1,126 +0,0 @@
# 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
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 01.00.39
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-16
View File
@@ -93,20 +93,8 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version
id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
@@ -183,7 +171,6 @@ jobs:
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -194,7 +181,6 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -231,7 +217,6 @@ jobs:
- name: Build package and upload
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
@@ -245,7 +230,6 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
@@ -47,6 +47,13 @@ jobs:
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+48 -2
View File
@@ -5,7 +5,52 @@ 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).
## [1.1.0] - Unreleased
## [1.2.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
- Leaflet map on location detail page with marker and popup (#57)
- Leaflet.markercluster for automatic marker grouping at low zoom levels (#61)
- Clustering toggle parameter in map module settings (enabled by default)
- Junction table orphan cleanup on location/category delete (#60)
- License key warning on install/update when no download key is configured
- Download key (dlid) preserved across package upgrades
### 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
- Category filter uses EXISTS subquery instead of JOIN to avoid ONLY_FULL_GROUP_BY errors (#59)
- Inline `<script>` blocks replaced with `$wa->addInlineScript()` for CSP nonce support (#34)
### Removed
- Dead `catid` column from locations table — junction table is the source of truth (#58)
- `idx_catid` index dropped from locations table
### 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
- CSP compatibility: all inline scripts use WebAssetManager for automatic nonce injection (#34)
- XSS fix: detail map popup uses DOM textContent instead of raw string in bindPopup()
### Fixed
- SQL migration compatibility: removed `DROP COLUMN IF EXISTS` (MySQL 8.0.13+ only) in favor of plain `DROP COLUMN`
## [1.1.0] - 2026-06-23
### Added
- Haversine proximity search — filter locations by distance from user's coordinates
@@ -18,9 +63,10 @@ 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: per-row validation via LocationTable::bind()->check()->store()
- CSV import view accessible from admin toolbar and submenu
- FocalPoint (Shack Locations) migration import
- Language strings for directions, geocoding feedback, and import UI
## [1.0.0] - 2026-06-23
## [01.00.00] - 2026-06-23
### Added
- Admin `LocationController` (FormController) for single-record save/cancel/apply
+68 -27
View File
@@ -6,41 +6,82 @@
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.
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).
You should have received a copy of the GNU General Public License (./LICENSE.md).
# FILE INFORMATION
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.01.00
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
DEFGROUP:
INGROUP: Project.Documentation
REPO:
VERSION: 01.00.39
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
# Code of Conduct
# Contributor Covenant Code of Conduct
## 1. Purpose
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone.
The purpose of this Code of Conduct is to ensure a safe, inclusive, and respectful environment for all contributors and participants in Moko Consulting projects. This applies to all interactions, whether in repositories, issue trackers, documentation, meetings, or community spaces.
## Our Standards
- Be empathetic and kind
- Be respectful of differing opinions
- Accept constructive feedback
- Own mistakes and learn from them
## 2. Our Standards
Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct.
Participants are expected to uphold behaviors that strengthen our community, including:
## Enforcement
Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly.
Demonstrating empathy and respect toward others.
Being inclusive of diverse viewpoints and backgrounds.
Gracefully accepting constructive feedback.
Prioritizing collaboration over conflict.
Showing professionalism in all interactions.
## Enforcement Guidelines
1. **Correction** — Private warning
2. **Warning** — Formal warning and limited interaction
3. **Temporary Ban** — Time-boxed exclusion
4. **Permanent Ban** — Removal from the community
### Unacceptable behavior includes:
## Attribution
Adapted from the Contributor Covenant v2.1.
Harassment, discrimination, or derogatory comments.
Threatening or violent language or actions.
Disruptive, aggressive, or intentionally harmful behavior.
Publishing others private information without permission.
Any behavior that violates applicable laws.
## 3. Responsibilities of Maintainers
Maintainers are responsible for:
Clarifying acceptable behavior.
Taking appropriate corrective action when unacceptable behavior occurs.
Removing, editing, or rejecting contributions that violate this Code.
Temporarily or permanently banning contributors who engage in repeated or severe violations.
## 4. Scope
This Code applies to:
All Moko Consulting repositories.
All documentation and collaboration platforms.
Public and private communication related to project activities.
Any representation of Moko Consulting in online or offline spaces.
## 5. Enforcement
Instances of misconduct may be reported to:
**[hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech)**
All reports will be reviewed and investigated promptly and fairly. Maintainers are obligated to maintain confidentiality where possible.
Consequences may include:
A warning.
Required training or mediation.
Temporary or permanent bans.
Escalation to legal authorities when required.
## 6. Acknowledgements
This Code of Conduct is inspired by widely adopted community guidelines, including the Contributor Covenant and major open-source collaboration standards.
## 7. Related Documents
[Governance Guide](./docs-governance.md)
[Contributor Guide](./docs-contributing.md)
[Documentation Index](./docs-index.md)
This Code of Conduct is a living document and may be updated following the established Change Management process.
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.Template-Joomla
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
VERSION: 01.01.00
VERSION: 01.00.39
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
-->
+19 -8
View File
@@ -1,6 +1,6 @@
# MokoSuiteStoreLocator
A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
## Package Contents
@@ -9,6 +9,7 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Web Services API | plugin (webservices) | `plg_webservices_mokosuitestorelocator` |
## Requirements
@@ -27,23 +28,33 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
### 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
- **Multi-category** — categories with parent/child hierarchy, color, custom marker icons, many-to-many assignments
- **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
- **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
- **Interactive map** — Leaflet.js with OpenStreetMap tiles, markers with popups, auto-fit bounds, marker clustering
- **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
- **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
- **Detail page map** — Leaflet map on single location view with marker and popup
- **Marker clustering** — Leaflet.markercluster groups nearby markers at low zoom levels (toggleable)
- **Security hardening** — CSV injection prevention, MIME validation, ORDER BY allowlists, CSP-compatible inline scripts
- **Junction cleanup** — orphan rows automatically removed when locations or categories are deleted
### 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
- Google Maps provider as alternative to Leaflet
- CSV export
- Photo gallery per location
- Address autocomplete on admin edit form
## Development
+36 -37
View File
@@ -19,11 +19,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
# FILE INFORMATION
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 01.01.00
VERSION: 01.00.39
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -31,7 +31,7 @@ BRIEF: Security vulnerability reporting and handling policy
## Purpose and Scope
This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues.
This document defines the security vulnerability reporting, response, and disclosure policy for [PROJECT_NAME] and all repositories governed by these standards. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues.
## Supported Versions
@@ -39,8 +39,8 @@ Security updates are provided for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 01.x.x | :white_check_mark: |
| < 01.0 | :x: |
| [X.x.x] | :white_check_mark: |
| < [X.0] | :x: |
Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches.
@@ -52,9 +52,9 @@ Only the current major version receives security updates. Users should upgrade t
Report security vulnerabilities privately to:
**Email**: `security@mokoconsulting.tech`
**Email**: `security@[DOMAIN]`
**Subject Line**: `[SECURITY] Template-Joomla - Brief Description`
**Subject Line**: `[SECURITY] Brief Description`
### What to Include
@@ -118,7 +118,7 @@ Security advisories are published via:
* GitHub Security Advisories
* Release notes and CHANGELOG.md
* Email notification to project users (if mailing list is established)
* Security mailing list (when established)
Advisories include:
@@ -131,7 +131,7 @@ Advisories include:
## Security Best Practices
For projects using this template:
For repositories adopting MokoStandards:
### Required Controls
@@ -141,17 +141,7 @@ For projects using this template:
* Enforce signed commits (recommended)
* Use secrets management (never commit credentials)
* Maintain security documentation
* Follow secure coding standards defined in MokoStandards
### Joomla Plugin Security
* Follow Joomla security best practices
* Validate and sanitize all user input
* Use Joomla's database API to prevent SQL injection
* Properly escape output to prevent XSS
* Implement proper access control checks
* Use Joomla's session and authentication APIs
* Keep Joomla and dependencies up to date
* Follow secure coding standards defined in `/docs/policy/`
### CI/CD Security
@@ -164,10 +154,10 @@ For projects using this template:
#### Automated Security Scanning
All repositories SHOULD implement:
All repositories MUST implement:
**CodeQL Analysis**:
* Enabled for PHP and other supported languages
* Enabled for all supported languages (Python, JavaScript, TypeScript, Java, C/C++, C#, Go, Ruby)
* Runs on: push to main, pull requests, weekly schedule
* Query sets: `security-extended` and `security-and-quality`
* Configuration: `.github/workflows/codeql-analysis.yml`
@@ -180,6 +170,14 @@ All repositories SHOULD implement:
**Secret Scanning**:
* Enabled by default with push protection
* Prevents accidental credential commits
* Partner patterns enabled
**Dependency Review**:
* Required for all pull requests
* Blocks introduction of known vulnerable dependencies
* Automatic license compliance checking
See [Security Scanning Policy](docs/policy/security-scanning.md) for detailed requirements.
### Dependency Management
@@ -191,7 +189,7 @@ All repositories SHOULD implement:
## Compliance and Governance
This security policy is aligned with MokoStandards. Deviations require documented justification.
This security policy is binding for all repositories governed by MokoStandards. Deviations require documented justification and approval from the Security Owner.
Security policies are reviewed and updated at least annually or following significant security incidents.
@@ -205,8 +203,8 @@ We acknowledge and appreciate responsible disclosure. With your permission, we w
## Contact and Escalation
* **Security Team**: security@mokoconsulting.tech
* **Primary Contact**: hello@mokoconsulting.tech
* **Security Team**: security@[DOMAIN]
* **Primary Contact**: [CONTACT_EMAIL]
* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub
## Out of Scope
@@ -224,18 +222,19 @@ The following are explicitly out of scope:
## Metadata
| Field | Value |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| Document | Security Policy |
| Path | /SECURITY.md |
| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) |
| Owner | Moko Consulting |
| Scope | Security vulnerability handling |
| Status | Active |
| Effective | 2026-01-16 |
| Field | Value |
| ------------ | ----------------------------------------------- |
| Document | Security Policy |
| Path | /SECURITY.md |
| Repository | [REPOSITORY_URL] |
| Owner | [OWNER_NAME] |
| Scope | Security vulnerability handling |
| Applies To | All repositories governed by MokoStandards |
| Status | Active |
| Effective | [YYYY-MM-DD] |
## Revision History
| Date | Change Description | Author |
| ---------- | ------------------------------------------------- | --------------- |
| 2026-01-16 | Initial creation for template repository | Moko Consulting |
| [YYYY-MM-DD] | Initial creation | [AUTHOR_NAME] |
@@ -0,0 +1,12 @@
<?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>
@@ -0,0 +1,69 @@
<?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>
@@ -0,0 +1,47 @@
<?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,6 +43,16 @@
</field>
</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">
<field
name="address"
@@ -2,6 +2,7 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
@@ -59,3 +60,22 @@ 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."
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"
@@ -2,6 +2,7 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
@@ -24,7 +24,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
`image` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 0,
`ordering` int(11) NOT NULL DEFAULT 0,
`catid` int(11) NOT NULL DEFAULT 0,
`params` text NOT NULL,
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`created_by` int(10) unsigned NOT NULL DEFAULT 0,
@@ -34,7 +33,41 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
`checked_out_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_catid` (`catid`),
KEY `idx_alias` (`alias`(191)),
KEY `idx_coordinates` (`latitude`, `longitude`)
) 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,4 +3,6 @@
-- 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`;
@@ -0,0 +1 @@
-- MokoSuiteStoreLocator 01.00.00 — Initial release, no schema changes needed.
@@ -0,0 +1,28 @@
-- 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;
@@ -0,0 +1,5 @@
-- MokoSuiteStoreLocator 01.00.02
-- Legacy catid column removed from install.mysql.sql.
-- No runtime migration needed — Joomla aborts on DROP errors
-- and fresh installs never had the column.
SELECT 1;
@@ -0,0 +1,37 @@
<?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);
}
}
@@ -0,0 +1,22 @@
<?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,6 +49,12 @@ class ImportController extends BaseController
$file = $this->input->files->get('jform', [], 'array');
$delimiter = $this->input->post->getString('delimiter', ',');
// Validate delimiter against allowlist
if (!\in_array($delimiter, [',', ';', '|', "\t"], true))
{
$delimiter = ',';
}
$csvFile = $file['csv_file'] ?? null;
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
@@ -59,6 +65,15 @@ class ImportController extends BaseController
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
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
@@ -70,6 +85,19 @@ class ImportController extends BaseController
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);
if ($result['imported'] > 0)
@@ -84,4 +112,45 @@ class ImportController extends BaseController
$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));
}
}
@@ -0,0 +1,46 @@
<?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', []);
}
}
@@ -0,0 +1,162 @@
<?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() ?: [];
}
}
@@ -0,0 +1,126 @@
<?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;
}
}
@@ -0,0 +1,85 @@
<?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,9 +206,192 @@ class ImportModel extends BaseDatabaseModel
foreach ($mapping as $dbField => $csvIndex)
{
$data[$dbField] = trim($row[$csvIndex] ?? '');
$value = trim($row[$csvIndex] ?? '');
$data[$dbField] = $this->sanitizeCsvValue($value);
}
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,6 +13,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Http\HttpFactory;
use Moko\Component\MokoSuiteStoreLocator\Administrator\Event\LocationSavedEvent;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
@@ -58,20 +59,6 @@ class LocationModel extends AdminModel
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.
*
@@ -118,7 +105,99 @@ class LocationModel extends AdminModel
}
}
return parent::save($data);
// Extract categories before parent::save (it won't know about junction table)
$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,10 +111,17 @@ class LocationsModel extends ListModel
->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));
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC');
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;
}
@@ -0,0 +1,112 @@
<?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;
use Joomla\Database\ParameterType;
/**
* 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();
}
/**
* Override delete to clean up junction table rows.
*
* @param mixed $pk Primary key value to delete.
*
* @return boolean True on success.
*
* @since 1.2.0
*/
public function delete($pk = null): bool
{
$pk = $pk ?: $this->id;
if (!parent::delete($pk))
{
return false;
}
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('category_id') . ' = :pk')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
return true;
}
}
@@ -15,6 +15,7 @@ use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
/**
* Location table class.
@@ -95,4 +96,34 @@ class LocationTable extends Table
return parent::check();
}
/**
* Override delete to clean up junction table rows.
*
* @param mixed $pk Primary key value to delete.
*
* @return boolean True on success.
*
* @since 1.2.0
*/
public function delete($pk = null): bool
{
$pk = $pk ?: $this->id;
if (!parent::delete($pk))
{
return false;
}
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :pk')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
return true;
}
}
@@ -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\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');
}
}
@@ -0,0 +1,54 @@
<?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');
}
}
@@ -0,0 +1,102 @@
<?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>
@@ -0,0 +1,52 @@
<?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,6 +60,21 @@ use Joomla\CMS\Session\Session;
</div>
<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-body">
<h4><?php echo Text::_('JHELP'); ?></h4>
@@ -37,6 +37,14 @@ HTMLHelper::_('behavior.keepalive');
</div>
<?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')); ?>
<div class="row">
<div class="col-lg-6">
@@ -0,0 +1,37 @@
<?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';
}
@@ -0,0 +1,68 @@
<?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">
<name>com_mokosuitestorelocator</name>
<version>1.0.0</version>
<version>01.00.39</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -38,14 +38,27 @@
</sql>
</uninstall>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site">
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<administration>
<files folder="admin">
<filename>access.xml</filename>
<folder>forms</folder>
<folder>language</folder>
<folder>services</folder>
@@ -57,6 +70,7 @@
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu>
<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>
</submenu>
</administration>
@@ -2,6 +2,7 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
@@ -99,6 +99,9 @@ class LocationsModel extends ListModel
$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);
}
@@ -154,6 +157,21 @@ class LocationsModel extends ListModel
->bind(':state', $state);
}
// Category filter — use EXISTS to avoid GROUP BY / ONLY_FULL_GROUP_BY issues
$catid = (int) $this->getState('filter.catid');
if ($catid > 0)
{
$subQuery = $db->getQuery(true)
->select('1')
->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc'))
->where($db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->where($db->quoteName('lc.category_id') . ' = :catid');
$query->where('EXISTS (' . $subQuery . ')')
->bind(':catid', $catid, ParameterType::INTEGER);
}
// Proximity / Haversine distance filter
$lat = $this->getState('filter.lat');
$lng = $this->getState('filter.lng');
@@ -179,10 +197,17 @@ class LocationsModel extends ListModel
}
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));
// Default 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;
@@ -14,6 +14,14 @@ use Joomla\CMS\Language\Text;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Location\HtmlView $this */
$item = $this->item;
if ($item->latitude && $item->longitude)
{
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->getDocument()->getWebAssetManager();
$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="com-mokosuitestorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
@@ -111,10 +119,29 @@ $item = $this->item;
<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); ?>"
id="mokosuitestorelocator-detail-map"
data-lat="<?php echo (float) $item->latitude; ?>"
data-lng="<?php echo (float) $item->longitude; ?>"
data-title="<?php echo $this->escape($item->title); ?>"
style="height: 300px;">
</div>
<?php
$wa->addInlineScript(<<<JS
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('mokosuitestorelocator-detail-map');
if (!el || typeof L === 'undefined') return;
var lat = parseFloat(el.getAttribute('data-lat'));
var lng = parseFloat(el.getAttribute('data-lng'));
var map = L.map(el.id).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
var span = document.createElement('span');
span.textContent = el.getAttribute('data-title') || '';
L.marker([lat, lng]).addTo(map).bindPopup(span).openPopup();
});
JS, [], ['position' => 'after'], ['leaflet']);
?>
<?php endif; ?>
</div>
@@ -2,6 +2,7 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_MAP="MokoSuite Store Locator - Map"
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
MOD_MOKOJOOMSTORELOCATOR_MAP_HEIGHT="Map Height"
@@ -9,4 +10,5 @@ MOD_MOKOJOOMSTORELOCATOR_MAP_ZOOM="Default Zoom Level"
MOD_MOKOJOOMSTORELOCATOR_MAP_PROVIDER="Map Provider"
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY="API Key"
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC="Required for Google Maps. Not needed for OpenStreetMap."
MOD_MOKOJOOMSTORELOCATOR_MAP_CLUSTERING="Enable Marker Clustering"
MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT="JavaScript is required to display the map."
@@ -2,5 +2,6 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_MAP="MokoSuite Store Locator - Map"
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
@@ -14,7 +14,7 @@
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_map</name>
<version>1.0.0</version>
<version>01.00.39</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -26,6 +26,7 @@
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorMap</namespace>
<files>
<folder module="mod_mokosuitestorelocator_map">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
<folder>language</folder>
@@ -66,6 +67,17 @@
label="MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY"
description="MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC"
/>
<field
name="enable_clustering"
type="radio"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_CLUSTERING"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
@@ -0,0 +1,25 @@
<?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,20 +41,29 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$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'),
$db->quoteName('a.id'),
$db->quoteName('a.title'),
$db->quoteName('a.address'),
$db->quoteName('a.city'),
$db->quoteName('a.state'),
$db->quoteName('a.postcode'),
$db->quoteName('a.phone'),
$db->quoteName('a.latitude'),
$db->quoteName('a.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');
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.published') . ' = 1')
->where($db->quoteName('a.latitude') . ' IS NOT NULL')
->where($db->quoteName('a.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);
$locations = $db->loadObjectList() ?: [];
@@ -63,7 +72,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
foreach ($locations as $loc)
{
$markers[] = [
$marker = [
'id' => (int) $loc->id,
'title' => $loc->title,
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
@@ -71,6 +80,18 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
'lat' => (float) $loc->latitude,
'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;
@@ -15,6 +15,11 @@ $params = $displayData['params'];
$locations = $displayData['locations'] ?? [];
$moduleId = $displayData['module']->id;
$mapHeight = $params->get('map_height', '400px');
if (!preg_match('/^\d+(px|em|rem|vh|%)$/', $mapHeight))
{
$mapHeight = '400px';
}
$mapZoom = (int) $params->get('map_zoom', 10);
$provider = $params->get('map_provider', 'leaflet');
$apiKey = $params->get('api_key', '');
@@ -22,10 +27,19 @@ $apiKey = $params->get('api_key', '');
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $displayData['app']->getDocument()->getWebAssetManager();
$enableClustering = (bool) $params->get('enable_clustering', 1);
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]);
if ($enableClustering)
{
$wa->registerAndUseStyle('leaflet.markercluster', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css', [], ['crossorigin' => '']);
$wa->registerAndUseStyle('leaflet.markercluster.default', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css', [], ['crossorigin' => '']);
$wa->registerAndUseScript('leaflet.markercluster', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js', [], ['crossorigin' => '', 'defer' => true], ['leaflet']);
}
}
?>
<div class="mod-mokosuitestorelocator-map"
@@ -40,9 +54,13 @@ if ($provider === 'leaflet')
</noscript>
</div>
<script>
<?php
$directionsText = Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true);
$clusteringJs = $enableClustering ? 'true' : 'false';
$wa->addInlineScript(<<<JS
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('mokosuitestorelocator-map-<?php echo (int) $moduleId; ?>');
var el = document.getElementById('mokosuitestorelocator-map-{$moduleId}');
if (!el || typeof L === 'undefined') return;
var locations = JSON.parse(el.getAttribute('data-locations') || '[]');
@@ -61,6 +79,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
var bounds = L.latLngBounds();
var useClustering = {$clusteringJs} && typeof L.markerClusterGroup === 'function';
var markerLayer = useClustering ? L.markerClusterGroup() : L.layerGroup();
function esc(str) {
var d = document.createElement('div');
@@ -69,15 +89,27 @@ document.addEventListener('DOMContentLoaded', function() {
}
locations.forEach(function(loc) {
var marker = L.marker([loc.lat, loc.lng]).addTo(map);
var markerOptions = {};
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);
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>';
popup += '<br><a href="https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng + '" target="_blank" rel="noopener">{$directionsText}</a>';
marker.bindPopup(popup);
markerLayer.addLayer(marker);
bounds.extend([loc.lat, loc.lng]);
});
map.addLayer(markerLayer);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: zoom });
});
</script>
JS, [], ['position' => 'after'], ['leaflet']);
?>
@@ -2,6 +2,7 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_SEARCH="MokoSuite Store Locator - Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL="Find a Store"
@@ -2,5 +2,6 @@
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_SEARCH="MokoSuite Store Locator - Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
@@ -14,7 +14,7 @@
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_search</name>
<version>1.0.0</version>
<version>01.00.39</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -26,6 +26,7 @@
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorSearch</namespace>
<files>
<folder module="mod_mokosuitestorelocator_search">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
<folder>language</folder>
@@ -0,0 +1,25 @@
<?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());
}
};
@@ -0,0 +1,2 @@
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuite Store Locator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
@@ -0,0 +1,6 @@
; MokoSuiteStoreLocator Web Services Plugin - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuite Store Locator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
@@ -0,0 +1,24 @@
<?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.39</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>
@@ -0,0 +1,37 @@
<?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;
}
);
}
};
@@ -0,0 +1,57 @@
<?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']
);
}
}
+6 -5
View File
@@ -16,27 +16,28 @@
=========================================================================
-->
<extension type="package" method="upgrade">
<name>pkg_mokosuitestorelocator</name>
<name>MokoSuite Store Locator</name>
<packagename>mokosuitestorelocator</packagename>
<version>1.0.0</version>
<version>01.00.39</version>
<creationDate>2026-06-23</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>PKG_MOKOJOOMSTORELOCATOR_DESC</description>
<description>MokoSuiteStoreLocator — store locator component with interactive map and search modules for Joomla 5/6.</description>
<dlid prefix="dlid=" suffix=""/>
<scriptfile>script.php</scriptfile>
<files>
<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>
<file type="plugin" id="mokosuitestorelocator" group="webservices">plg_webservices_mokosuitestorelocator.zip</file>
</files>
<updateservers>
<server type="extension" name="MokoSuiteStoreLocator Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server>
<server type="extension" priority="1" name="Package - MokoSuiteStoreLocator">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server>
</updateservers>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
</extension>
+113
View File
@@ -69,6 +69,8 @@ class Pkg_MokosuitestorelocatorInstallerScript implements InstallerScriptInterfa
return false;
}
$this->saveDownloadKey();
return true;
}
@@ -126,6 +128,117 @@ class Pkg_MokosuitestorelocatorInstallerScript implements InstallerScriptInterfa
*/
public function postflight(string $type, InstallerAdapter $parent): bool
{
$this->restoreDownloadKey();
$this->warnMissingLicenseKey();
return true;
}
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_mokosuitestorelocator'))
->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_mokosuitestorelocator'))
->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('%MokoSuiteStoreLocator%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteStoreLocator%') . ')'
)
->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) {}
}
}