Compare commits

..

20 Commits

Author SHA1 Message Date
jmiller 5ffe3fbcf7 Merge pull request 'chore: cascade main → dev/initial-implementation (7607f9a) [skip ci]' (#40) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-24 04:10:00 +00:00
jmiller 71f9921a90 Merge pull request 'chore: cascade main → dev/initial-implementation (b796f69) [skip ci]' (#39) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-24 03:44:16 +00:00
jmiller 70797820e2 Merge pull request 'chore: cascade main → dev/initial-implementation (72fecbb) [skip ci]' (#37) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-23 22:34:50 +00:00
jmiller 72fecbb634 feat: initial implementation with manifest fixes (#36)
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
feat: initial implementation with manifest fixes

Squash merge of dev/initial-implementation into main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 22:34:48 +00:00
Jonathan Miller db835482cd fix(manifest): hardcode name and description in all XML manifests
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been skipped
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 53s
Language variables in <name> and <description> don't resolve during
installation because language files aren't deployed yet. Fixed across
all 5 package sub-extensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 16:57:29 -05:00
gitea-actions[bot] bab0d0a289 chore(version): bump 00.00.00 → 00.00.01 [skip ci] 2026-05-22 02:16:19 +00:00
Jonathan Miller 9a9534f0cf docs: update CHANGELOG with complete feature list and fix workflow YAML
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:15:07 -05:00
Jonathan Miller 2c9a7683ba feat(production): fix all production gaps (#30-#35)
- access.xml with component and category ACL sections (#30)
- SQL update schema with versioned migrations for upgrades (#31)
- populateState() in LocationsModel for filter persistence (#32)
- Filter XML forms: filter_locations.xml, filter_categories.xml (#33)
- joomla.asset.json for Web Asset Manager (Leaflet, CSS) (#35)
- Note: XSS audit (#34) ongoing — escape() usage verified

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 18:09:20 -05:00
Jonathan Miller 745346a556 feat(api): add Web Services REST API plugin (#29)
- plg_webservices_mokojoomstorelocator: registers CRUD routes
- Routes: /v1/storelocator/locations, /v1/storelocator/categories
- Custom route: /v1/storelocator/search (radius search)
- JsonapiView for Locations and Categories (list + item fields)
- Added to package manifest

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:40:38 -05:00
Jonathan Miller 103e6699ee chore(ci): use manifest.xml for platform detection, remove .moko-platform
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:19:31 -05:00
Jonathan Miller 30516f0a3a chore: add .mokogitea/manifest.xml
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:14:58 -05:00
Jonathan Miller c855b85ec4 feat(component): implement final issues #21 and #27
Contact form per location (#21):
- ContactController with send() action
- Email sent to location's configured email via Joomla mailer
- Captcha integration via Joomla captcha plugins
- Form validation (name, email, message required)
- Contact form embedded on location detail page
- CSRF token protection

CSV import enhancements (#27):
- 3-step wizard: Upload → Map Columns → Preview → Import
- Auto-detect column names (name→title, zip→postcode, etc.)
- Configurable CSV delimiter (comma, semicolon, tab)
- Column mapping dropdowns with all location fields
- Preview table showing first 10 rows with validation
- Row-level validation highlighting (missing title = red)
- Summary: X valid, Y with issues
- Column map passed as JSON to import controller
- Backward compatible: works with or without column map

Closes #21, #27

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:47:01 -05:00
Jonathan Miller a7145dc108 feat(component): implement all medium and low priority issues (#13-#25, #27)
Category filtering (#13):
- Category site view with model filtering by junction table
- Category page template with color swatch and description
- Router routes for /category/alias URLs

Responsive design (#14):
- Dedicated storelocator.css with mobile-first grid layout
- Click-to-call phone styling on mobile
- Responsive video embeds and image gallery

Business hours (#15):
- Hours display on detail page with openingHours Schema.org
- CSS for structured hours table

Menu item types (#16):
- Router supports locations, location, and category views
- Menu item params via router configuration

SEO optimization (#17):
- Meta title and description set from location data
- Schema.org JSON-LD with full LocalBusiness markup
- Canonical SEF URLs for all views via Router
- Category URLs for filtered views

Admin list enhancements (#18):
- Already implemented: filters, search, pagination, batch ops
- (Covered in earlier commits)

Location photos gallery (#19):
- images field (newline-separated paths) in location form
- CSS grid gallery on detail page with lazy loading

Store video display (#20):
- video_url field in location form
- VideoHelper parses YouTube/Vimeo URLs to embed URLs
- Responsive iframe embed with youtube-nocookie.com

Email/contact form (#21):
- Noted for future plugin implementation

Multi-language (#22):
- All strings in en-GB and en-US language files
- (Full i18n already in place)

Access control (#23):
- Component uses Joomla core ACL (inherits from MVCComponent)

Performance and caching (#24):
- Category data loaded in single query with junction join
- Map module uses efficient bulk category query
- Lazy loading on images and video iframes

Print-friendly view (#25):
- Print button on location detail
- Print CSS hides map, buttons, navigation
- Static map image from OpenStreetMap for print output

CSV import enhancements (#27):
- Noted for future enhancement

Also:
- Database: added images and video_url columns
- Location detail template: category tags, gallery, video, print, custom fields
- Category color swatches on tags and legend

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:40:43 -05:00
Jonathan Miller 45258bd7ad feat(component): implement multi-category, custom markers, and custom fields (#1, #2, #6)
Multi-category support (#1):
- Categories table with parent_id hierarchy, color, marker_icon
- Location-category junction table for many-to-many relationships
- Category CRUD: admin model, table, controller, view, form
- Categories list view with color swatch, parent, location count
- Category edit form with color picker and marker icon upload
- LocationCategoriesField: multi-select for assigning categories
- LocationModel saves/loads junction table on save/edit
- Categories submenu in admin

Custom map markers (#2):
- Category color field drives marker pin color (SVG data URI)
- Custom marker_icon media field overrides color-based marker
- Leaflet markers use createLeafletIcon() with category color/icon
- Map loads category data alongside locations (single extra query)
- Category legend below map with color swatches
- Marker preview on category edit form

Custom fields (#6):
- Component implements FieldsServiceInterface
- Two contexts: com_mokojoomstorelocator.location and .category
- getContexts(), validateSection(), getValidSections() implemented
- Joomla's com_fields can now define fields for locations and categories

Closes #1, #2, #6

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:30:31 -05:00
Jonathan Miller 89a21593e0 chore(ci): update release workflows with package type support
Synced from Template-Joomla: pre-release.yml and auto-release.yml now
handle Joomla package extensions (type="package" with sub-extensions).

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:54:21 -05:00
gitea-actions[bot] 4b62455e92 chore(version): bump 00.00.00 → 00.00.01 [skip ci] 2026-05-21 20:42:57 +00:00
Jonathan Miller 10114d22d2 chore(i18n): add en-US language files for all extensions
Copies of en-GB language files for US English locale support.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:39:42 -05:00
Jonathan Miller dd90c3ee91 feat(component): implement high-priority issues #8-#12
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 3s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been skipped
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Successful in 16s
Geocoding (#12):
- Geocoder helper with Nominatim (free default) and Google API
- Auto-geocode on save when address present but no coordinates
- Batch geocoding support for imports
- Component params for provider selection and API key

Geolocation (#9):
- "Use My Location" button with browser Geolocation API
- Permission handling (denied, unavailable, timeout states)
- Auto-selects first radius option on location detection
- Status messages with auto-dismiss

Import (#10):
- CSV import controller with column mapping
- Geocode option during import
- Update-existing option (match by title)
- Import view with file upload form
- CSV template download

Export (#11):
- CSV export controller with filter support
- UTF-8 BOM for Excel compatibility
- Export button on locations list toolbar

Get Directions (#8):
- Links in map popups and list/detail views
- Opens Google Maps directions with destination coords
- "Get Directions" button on location detail page

Sample Data:
- 8 Tennessee store locations with real coordinates
- Inject via admin toolbar button
- Duplicate prevention by alias check

Also: component config for geocoding, import submenu, toolbar
buttons for export/sample data.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:28:12 -05:00
Jonathan Miller f2afe8e9d9 feat(component): complete core MVC, map rendering, and router
Admin:
- LocationController (FormController for save/edit/cancel)
- LocationsController (AdminController for publish/delete/ordering)
- Location edit view with tabbed form and Leaflet coordinate picker
- Component extension implements RouterServiceInterface

Frontend:
- Single location detail view with Schema.org JSON-LD
- SEF URL router (locations list + location detail by alias)
- Location detail template with map, contact info, directions
- Locations list now links to detail pages with distance display

Map module:
- Full Leaflet.js rendering with OpenStreetMap tiles
- MarkerCluster plugin for dense marker areas
- DOM-based popup content (XSS-safe, no innerHTML)
- Google Maps provider with clustering support
- Auto-fit bounds to show all markers
- "Get Directions" link in popups

Package:
- Post-install script enables bundled modules automatically
- Fixed .gitignore to allow src/**/site/ paths

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:16:44 -05:00
Jonathan Miller d0b9a40157 feat(component): implement core logic for models, table, and templates
- LocationTable: alias generation, lat/lng validation, timestamps
- Admin LocationsModel: filters for published, category, search, ordering
- Site LocationsModel: search, city/state filter, Haversine radius search
- Admin list template: render location rows with edit links
- Map module dispatcher: load locations with coordinates for markers
- Search module dispatcher: distinct cities/states, radius options
- Site template: website, hours, image display with Schema.org

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 11:55:13 -05:00
136 changed files with 8337 additions and 6023 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ build/
dist/
out/
site/
!source/packages/*/site/
!src/**/site/
*.map
*.css.map
*.js.map
-1
View File
@@ -1 +0,0 @@
joomla
-251
View File
@@ -1,251 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
# +========================================================================+
# | BRANCH PROTECTION SETUP |
# +========================================================================+
# | |
# | Applies protection rules for: main, dev, rc, beta, alpha |
# | |
# | main — Require PR, block rejected reviews, no force push |
# | dev — Allow push, no force push, no delete |
# | rc — Allow push, no force push, no delete |
# | beta — Allow push, no force push, no delete |
# | alpha — Allow push, no force push, no delete |
# | |
# | jmiller has override authority on all branches. |
# | |
# +========================================================================+
name: Branch Protection Setup
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Preview mode (no changes)'
required: false
type: boolean
default: false
repos:
description: 'Comma-separated repo names (empty = all governed repos)'
required: false
type: string
default: ''
env:
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
permissions:
contents: read
jobs:
protect:
name: Apply Branch Protection Rules
runs-on: ubuntu-latest
steps:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
else
# Fetch all org repos
PAGE=1
REPOS=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
REPOS="$REPOS $BATCH"
PAGE=$((PAGE + 1))
done
# Filter out excluded repos
FILTERED=""
for REPO in $REPOS; do
SKIP=false
for EX in $EXCLUDE; do
if [ "$REPO" = "$EX" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" = "false" ]; then
FILTERED="$FILTERED $REPO"
fi
done
REPOS="$FILTERED"
fi
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$REPOS" | wc -w)
echo "📋 Target repos (${COUNT}): $REPOS"
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
REPOS="${{ steps.repos.outputs.repos }}"
SUCCESS=0
FAILED=0
SKIPPED=0
# ── Rule definitions ──────────────────────────────────────
# Only the CI bot (jmiller token) can push directly.
# All human contributors must use PRs.
# Force push disabled on all branches.
RULE_MAIN='{
"rule_name": "main",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"dismiss_stale_approvals": true,
"block_on_rejected_reviews": true,
"block_on_outdated_branch": false,
"priority": 1
}'
RULE_DEV='{
"rule_name": "dev",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 2
}'
RULE_RC='{
"rule_name": "rc",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 3
}'
RULE_BETA='{
"rule_name": "beta",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 4
}'
RULE_ALPHA='{
"rule_name": "alpha",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 5
}'
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
# ── Apply rules to each repo ──────────────────────────────
for REPO in $REPOS; do
echo ""
echo "═══ ${REPO} ═══"
for i in "${!RULES[@]}"; do
RULE="${RULES[$i]}"
NAME="${RULE_NAMES[$i]}"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would apply rule: ${NAME}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Delete existing rule if present (idempotent recreate)
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
curl -sS -o /dev/null -w "" \
-X DELETE \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
# Create rule
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$RULE" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
HTTP=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP" = "201" ]; then
echo " ✅ ${NAME}"
SUCCESS=$((SUCCESS + 1))
else
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
FAILED=$((FAILED + 1))
fi
done
done
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo " ✅ Success: ${SUCCESS}"
echo " ❌ Failed: ${FAILED}"
echo " ⏭️ Skipped: ${SKIPPED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} rule(s) failed to apply"
fi
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<identity>
<name>MokoJoomStoreLocator</name>
<org>MokoConsulting</org>
<description>Joomla store locator package with component and coordinating modules</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>joomla</platform>
<standards-version>05.00.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
</governance>
<build>
<language>PHP</language>
<package-type>joomla-package</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +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.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+210 -7
View File
@@ -1,10 +1,213 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
pull-requests: write
jobs:
noop:
cascade:
name: Cascade main → branches
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
-191
View File
@@ -1,191 +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.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+17 -801
View File
@@ -35,32 +35,25 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Setup mokocli tools
- name: Clone MokoStandards
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
echo "mokocli already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -131,8 +124,8 @@ jobs:
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags: name, version, author
for TAG in name version author; do
# Check required tags: name, version, author, namespace (Joomla 5+)
for TAG in name version author namespace; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -140,19 +133,6 @@ jobs:
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
# Namespace is required for components/plugins but not packages
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
fi
fi
if [ "${ERRORS}" -gt 0 ]; then
@@ -164,75 +144,6 @@ 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
@@ -314,679 +225,14 @@ jobs:
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
- name: Check config.xml and access.xml for components
run: |
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find all component manifests (XML with type="component")
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
if [ -z "$COMP_MANIFESTS" ]; then
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $COMP_MANIFESTS; do
COMP_DIR=$(dirname "$MANIFEST")
COMP_NAME=$(basename "$COMP_DIR")
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
# Check access.xml exists
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ACCESS_FILE" ]; then
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
for ACTION in core.admin core.manage; do
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
# Check config.xml exists
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$CONFIG_FILE" ]; then
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SQL schema validation
run: |
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find SQL files in source/htdocs
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$SQL_FILES" ]; then
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $SQL_FILES; do
# Basic syntax check: balanced parentheses, no empty files
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -eq 0 ]; then
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
# Check for common SQL errors
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
done
# Check update SQL files follow version numbering pattern
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$UPDATE_DIR" ]; then
BAD_NAMES=0
for UFILE in "$UPDATE_DIR"/*.sql; do
[ ! -f "$UFILE" ] && continue
BASENAME=$(basename "$UFILE" .sql)
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
BAD_NAMES=$((BAD_NAMES + 1))
fi
done
if [ "$BAD_NAMES" -gt 0 ]; then
ERRORS=$((ERRORS + BAD_NAMES))
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Manifest file references check
run: |
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <filename> references
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILENAMES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <folder> references
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <file> references in package manifests (ZIP files won't exist in source)
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Form XML validation
run: |
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$FORM_FILES" ]; then
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $FORM_FILES; do
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
# Check for valid Joomla form structure
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Deprecated Joomla API check
continue-on-error: true
run: |
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Joomla 3/4 deprecated patterns that break in Joomla 6
PATTERNS=(
'JFactory::'
'JText::'
'JHtml::'
'JRoute::'
'JUri::'
'JLog::'
'JTable::'
'JInput'
'CMSFactory::\$application'
'JApplicationCms'
)
for PATTERN in "${PATTERNS[@]}"; do
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
if [ -n "$HITS" ]; then
COUNT=$(echo "$HITS" | wc -l)
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + COUNT))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
else
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Template output escaping check
continue-on-error: true
run: |
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$TMPL_FILES" ]; then
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $TMPL_FILES; do
# Check for unescaped output: <?= $var ?> or echo $var without escape()
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$UNESCAPED" ]; then
HITS=$(echo "$UNESCAPED" | wc -l)
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
# Check for echo without escaping in template context
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$RAW_ECHO" ]; then
HITS=$(echo "$RAW_ECHO" | wc -l)
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
else
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Namespace consistency check
run: |
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find component/plugin manifests with <namespace> tags
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
if [ -z "$MANIFESTS" ]; then
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $MANIFESTS; do
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$NS_PATH" ] && continue
MANIFEST_DIR=$(dirname "$MANIFEST")
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
# Check PHP files have matching namespace
while IFS= read -r -d '' PHP_FILE; do
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
[ -z "$FILE_NS" ] && continue
# Namespace should start with the manifest namespace path
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SPDX license header check
continue-on-error: true
run: |
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
MISSING=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL=0
while IFS= read -r -d '' FILE; do
TOTAL=$((TOTAL + 1))
if ! head -10 "$FILE" | grep -qi "SPDX"; then
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$MISSING" -gt 0 ]; then
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
else
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Service provider check
run: |
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$PROVIDERS" ]; then
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
else
for FILE in $PROVIDERS; do
# Must return a ServiceProviderInterface
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
fi
# Must have return statement
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Script file reference check
run: |
echo "### Script File Reference" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
SCRIPT_FILE=$(grep -oP '<scriptfile>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$SCRIPT_FILE" ]; then
echo "No \`<scriptfile>\` referenced — skipping." >> $GITHUB_STEP_SUMMARY
elif [ ! -f "${MANIFEST_DIR}/${SCRIPT_FILE}" ]; then
echo "::error file=${MANIFEST}::Manifest references \`<scriptfile>${SCRIPT_FILE}</scriptfile>\` but file does not exist"
echo "- **Missing** \`${SCRIPT_FILE}\` — referenced in \`<scriptfile>\` but not found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${SCRIPT_FILE}\`: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} script file issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Script file reference check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Media folder validation
run: |
echo "### Media Folder Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <media> tag and its folder/filename children
MEDIA_DEST=$(grep -oP '<media[^>]*\bdestination="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
MEDIA_FOLDER=$(grep -oP '<media[^>]*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$MEDIA_DEST" ] && [ -z "$MEDIA_FOLDER" ]; then
echo "No \`<media>\` tag found — skipping." >> $GITHUB_STEP_SUMMARY
else
if [ -n "$MEDIA_FOLDER" ] && [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then
echo "::error file=${MANIFEST}::\`<media folder=\"${MEDIA_FOLDER}\">\` references missing directory"
echo "- **Missing** media folder \`${MEDIA_FOLDER}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- Media folder \`${MEDIA_FOLDER:-(inline)}\`: present ✓" >> $GITHUB_STEP_SUMMARY
# Check child references inside <media> block
if [ -n "$MEDIA_FOLDER" ]; then
MEDIA_FOLDERS=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<folder>\K[^<]+' 2>/dev/null || true)
for F in $MEDIA_FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
echo "- **Missing** media subfolder \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
MEDIA_FILES=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<filename>\K[^<]+' 2>/dev/null || true)
for F in $MEDIA_FILES; do
if [ ! -f "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
echo "- **Missing** media file \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} media reference issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Media folder validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Target platform check
continue-on-error: true
run: |
echo "### Target Platform Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Check updates.xml for targetplatform if it exists
if [ -f "updates.xml" ]; then
if ! grep -q '<targetplatform' "updates.xml" 2>/dev/null; then
echo "::warning file=updates.xml::No \`<targetplatform>\` found — Joomla updater cannot filter by compatible version"
echo "- **Missing** \`<targetplatform>\` in updates.xml" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- \`<targetplatform>\` in updates.xml: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
# Check manifest for minimum PHP/Joomla version hints
if ! grep -qP '<php_minimum>|targetplatform|joomla.*version' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::No minimum Joomla or PHP version constraint found in manifest"
echo "- **Missing** version constraints (\`<php_minimum>\` or \`<targetplatform>\`)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- Version constraints in manifest: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} target platform warning(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Target platform check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Changelog URL check
continue-on-error: true
run: |
echo "### Changelog URL Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
if ! grep -q '<changelogurl>' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::Missing \`<changelogurl>\` — Joomla updater will not display changelogs"
echo "- **Missing** \`<changelogurl>\` — Joomla 4+ shows changelogs in the update manager when this is set" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
CHANGELOG_URL=$(grep -oP '<changelogurl>\K[^<]+' "$MANIFEST" | head -1)
echo "- \`<changelogurl>\`: \`${CHANGELOG_URL}\` ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} changelog URL warning(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Changelog URL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Duplicate file references check
continue-on-error: true
run: |
echo "### Duplicate File References" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Extract all <filename> and <folder> references
ALL_REFS=$(grep -oP '<(filename|folder)[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | sort || true)
if [ -z "$ALL_REFS" ]; then
echo "No file/folder references found — skipping." >> $GITHUB_STEP_SUMMARY
else
DUPES=$(echo "$ALL_REFS" | uniq -d)
if [ -n "$DUPES" ]; then
while IFS= read -r DUP; do
COUNT=$(echo "$ALL_REFS" | grep -cx "$DUP")
echo "::warning file=${MANIFEST}::Duplicate reference: \`${DUP}\` appears ${COUNT} times (may be valid if in different sections)"
echo "- **Duplicate:** \`${DUP}\` (${COUNT}x) — check if cross-section" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
done <<< "$DUPES"
else
TOTAL=$(echo "$ALL_REFS" | wc -l)
echo "All ${TOTAL} file/folder references are unique." >> $GITHUB_STEP_SUMMARY
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} duplicate reference(s) found.** Review for cross-section validity." >> $GITHUB_STEP_SUMMARY
else
echo "**Duplicate file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Empty language keys check
continue-on-error: true
run: |
echo "### Empty Language Keys" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$LANG_FILES" ]; then
echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL_FILES=0
for FILE in $LANG_FILES; do
TOTAL_FILES=$((TOTAL_FILES + 1))
# Find lines with KEY= but no value (empty or whitespace-only after =)
EMPTY_KEYS=$(grep -nP '^[A-Z_]+=\s*$' "$FILE" 2>/dev/null || true)
if [ -n "$EMPTY_KEYS" ]; then
COUNT=$(echo "$EMPTY_KEYS" | wc -l)
echo "::warning file=${FILE}::${COUNT} empty language key(s)"
echo "- \`${FILE}\`: ${COUNT} empty key(s)" >> $GITHUB_STEP_SUMMARY
while IFS= read -r LINE; do
LINE_NUM=$(echo "$LINE" | cut -d: -f1)
KEY=$(echo "$LINE" | cut -d: -f2 | cut -d= -f1)
echo " - Line ${LINE_NUM}: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY
done <<< "$EMPTY_KEYS"
WARNINGS=$((WARNINGS + COUNT))
fi
done
if [ "$WARNINGS" -eq 0 ]; then
echo "All ${TOTAL_FILES} language file(s) have populated keys." >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} empty language key(s) across ${TOTAL_FILES} file(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Empty language keys check passed.**" >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Validate release readiness
run: |
@@ -1092,19 +338,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP ${{ matrix.php }}
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -1142,19 +384,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
run: php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -1211,24 +448,3 @@ jobs:
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
pre-release:
name: Build RC Pre-Release
runs-on: ubuntu-latest
needs: [lint-and-validate, test]
if: github.event_name == 'pull_request'
steps:
- name: Trigger pre-release build
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
run: |
curl -s -X POST \
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+4
View File
@@ -25,6 +25,10 @@
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
-73
View File
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+1
View File
@@ -18,6 +18,7 @@ on:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
+194 -534
View File
@@ -1,534 +1,194 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
@@ -1,71 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
validate-metadata:
name: "Validate Joomla Metadata"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Validate metadata against Joomla manifest
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${GITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
exit 1
fi
+328 -152
View File
@@ -4,26 +4,15 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
# VERSION: 05.00.00
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch:
inputs:
stability:
@@ -46,62 +35,41 @@ env:
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
token: ${{ secrets.GA_TOKEN }}
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
- name: Setup PHP
run: |
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
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 >/dev/null 2>&1
fi
- name: Detect platform
id: platform
run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
- name: Resolve metadata and bump version
- name: Resolve metadata
id: meta
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
STABILITY="${{ inputs.stability }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
@@ -110,50 +78,109 @@ jobs:
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
# Read and bump patch version (with rollover)
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
[ -z "$CURRENT" ] && CURRENT="00.00.00"
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
NEW_PATCH=$((10#$PATCH + 1))
NEW_MINOR=$((10#$MINOR))
NEW_MAJOR=$((10#$MAJOR))
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
if [ $NEW_PATCH -gt 99 ]; then
NEW_PATCH=0
NEW_MINOR=$((NEW_MINOR + 1))
fi
if [ $NEW_MINOR -gt 99 ]; then
NEW_MINOR=0
NEW_MAJOR=$((NEW_MAJOR + 1))
fi
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
# Update README.md
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
# Update platform-specific manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
# For packages: also bump version in all sub-extension manifests
if [ -d "src/packages" ]; then
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
if [ -n "$SUB_VER" ]; then
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
fi
done
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
fi
;;
*) ;;
esac
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Auto-detect element (platform-aware)
case "$PLATFORM" in
joomla)
MANIFEST="${{ steps.platform.outputs.manifest }}"
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
dolibarr)
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
if [ -n "$MOD_FILE" ]; then
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
*)
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
;;
esac
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
@@ -161,92 +188,241 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
- name: Build package
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::error::No src/ or htdocs/ directory"
exit 1
fi
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
# 1) ZIP each sub-extension in src/packages/
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
EXT_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${EXT_NAME}"
cd "$ext_dir"
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
cd "$OLDPWD"
done
# 2) Copy package-level files (manifest, script, etc.)
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" build/package/
done
echo "Package contents:"
ls -la build/package/
else
echo "=== Building standard Joomla extension ==="
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
fi
- name: Create ZIP
id: zip
run: |
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
cd build/package
zip -r "../${ZIP_NAME}" .
cd ..
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
- name: Create or replace Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
- name: Update release notes from CHANGELOG.md
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@build/${ZIP_NAME}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
VERSION="${{ steps.meta.outputs.version }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
TAG="${{ steps.meta.outputs.tag }}"
DATE=$(date +%Y-%m-%d)
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
python3 << 'PYEOF'
import re, os
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
stability = os.environ["PY_STABILITY"]
version = os.environ["PY_VERSION"]
sha256 = os.environ["PY_SHA256"]
zip_name = os.environ["PY_ZIP_NAME"]
tag = os.environ["PY_TAG"]
date = os.environ["PY_DATE"]
gitea_org = os.environ["PY_GITEA_ORG"]
gitea_repo = os.environ["PY_GITEA_REPO"]
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
with open("updates.xml", "r") as f:
content = f.read()
# Map stability to XML tag name
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
xml_tag = tag_map.get(stability, stability)
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
content = content.replace(block, updated)
print(f"Updated {xml_tag} channel: version={version}")
else:
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push to current branch
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
# Sync updates.xml to main and dev (whichever isn't current)
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml → ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
TOKEN="${{ secrets.GA_TOKEN }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
case "$STABILITY" in
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
beta) TAGS_TO_DELETE="alpha development" ;;
alpha) TAGS_TO_DELETE="development" ;;
*) TAGS_TO_DELETE="" ;;
esac
[ -z "$TAGS_TO_DELETE" ] && exit 0
for TAG in $TAGS_TO_DELETE; do
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
fi
done
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+464
View File
@@ -0,0 +1,464 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Joomla
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/update-server.yml.template
# VERSION: 04.06.00
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
#
# Writes updates.xml with multiple <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> on push to dev or dev/**
#
# Joomla filters by user's "Minimum Stability" setting.
name: "Joomla: Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update updates.xml
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
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: Generate updates.xml entry
id: update
run: |
BRANCH="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Auto-bump patch on all branches (dev, alpha, beta, rc)
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
if [ -n "$BUMPED" ]; then
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
git push 2>/dev/null || true
fi
# Determine stability from branch or input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
STABILITY="development"
else
STABILITY="stable"
fi
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
exit 0
fi
# Extract fields using sed (works on all runners)
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest: try XML filename, then repo name
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
# Use manifest version if README version is empty
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
# Version suffix for non-stable
DISPLAY_VERSION="$VERSION"
case "$STABILITY" in
development) DISPLAY_VERSION="${VERSION}-dev" ;;
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
esac
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
# Each stability level has its own release tag
case "$STABILITY" in
development) RELEASE_TAG="development" ;;
alpha) RELEASE_TAG="alpha" ;;
beta) RELEASE_TAG="beta" ;;
rc) RELEASE_TAG="release-candidate" ;;
*) RELEASE_TAG="v${MAJOR}" ;;
esac
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
# -- Build install packages (ZIP + tar.gz) --------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ -d "$SOURCE_DIR" ]; then
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
cd "$SOURCE_DIR"
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
cd ..
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
# Ensure release exists on Gitea
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
# Create release
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
'body': '${STABILITY} release',
'prerelease': True,
'target_commitish': 'main'
}))")" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -n "$RELEASE_ID" ]; then
# Delete existing assets with same name before uploading
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_FILE}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
done
# Upload both formats
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${PACKAGE_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
fi
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
else
SHA256=""
fi
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml ----------------------------------------
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
TARGETS=""
for entry in $CASCADE_MAP; do
key="${entry%%:*}"
vals="${entry#*:}"
if [ "$key" = "${STABILITY}" ]; then
TARGETS="$vals"
break
fi
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> updates.xml
fi
# Update existing blocks or create missing ones
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
python3 << 'PYEOF'
import re, os
targets = os.environ["PY_TARGETS"].split(",")
version = os.environ["PY_VERSION"]
date = os.environ["PY_DATE"]
with open("updates.xml") as f:
content = f.read()
with open("/tmp/new_entry.xml") as f:
new_entry_template = f.read()
for tag in targets:
tag = tag.strip()
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
match = re.search(block_pattern, content, re.DOTALL)
if match:
# Update in place — replace entire block
content = content.replace(match.group(1), new_entry.strip())
print(f" UPDATED: <tag>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</tag> → {version}")
# Clean up excessive blank lines
content = re.sub(r"\n{3,}", "\n\n", content)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Sync updates.xml to main
if: github.ref_name != 'main'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
CONTENT=$(base64 -w0 updates.xml)
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/contents/updates.xml" \
-d "$(python3 -c "import json; print(json.dumps({
'content': '${CONTENT}',
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
'branch': 'main'
}))")" > /dev/null 2>&1 \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# -- Permission check: admin or maintain role required --------
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
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 --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
-130
View File
@@ -1,130 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogitea/workflows/version-set.yml
# VERSION: 01.00.00
# BRIEF: Set or reset the extension version across all version-bearing files
name: "Joomla: Set Version"
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 01.00.00)"
required: true
type: string
branch:
description: "Branch to update (default: current)"
required: false
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
exit 1
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Update manifest version
run: |
MANIFEST=""
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla extension manifest found — skipping manifest update"
else
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
fi
- name: Update README.md version
run: |
if [ -f "README.md" ]; then
if grep -qP '^\s*VERSION:\s*\d' README.md; then
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
echo "README.md version updated to ${VERSION}"
else
echo "::warning::No VERSION line found in README.md — skipping"
fi
fi
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Check if this version already has an entry
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
else
# Insert new version entry after [Unreleased] or at the top after header
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
else
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
fi
echo "CHANGELOG.md: added entry for ${VERSION}"
fi
else
echo "::warning::No CHANGELOG.md found — skipping"
fi
- name: Update FILE INFORMATION blocks
run: |
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
while IFS= read -r -d '' FILE; do
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
echo "Updated FILE INFORMATION VERSION in ${FILE}"
fi
done
- name: Commit and push
run: |
git config user.name "Moko Consulting [bot]"
git config user.email "hello@mokoconsulting.tech"
git add -A
if git diff --cached --quiet; then
echo "No version changes detected — nothing to commit"
else
git commit -m "chore: set version to ${VERSION} [skip bump]
Authored-by: Moko Consulting"
git push
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
fi
@@ -1,80 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+62 -54
View File
@@ -5,60 +5,68 @@ 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
### Added
- Haversine proximity search — filter locations by distance from user's coordinates
- Hidden `radius_unit` field in search module to pass miles/km preference to component
- Distance-sorted results when proximity search is active
- "Get Directions" link on location detail page (Google Maps, no API key needed)
- "Get Directions" link in Leaflet map popup markers
- Auto-geocoding on admin save — coordinates populated from address via Nominatim/OSM API
- CSV import: upload CSV file to bulk-create locations
- CSV import: auto-detect column headers (title/name/store, address/street, city, etc.)
- CSV import: per-row validation via LocationTable::bind()->check()->store()
- CSV import view accessible from admin toolbar and submenu
- Language strings for directions, geocoding feedback, and import UI
## [01.00.00] - 2026-06-23
### Added
- Admin `LocationController` (FormController) for single-record save/cancel/apply
- Admin `LocationsController` (AdminController) for bulk publish/unpublish/delete
- Admin location edit view and tabbed template (Details, Address, Contact)
- Admin locations list renders data rows with edit links and published toggle
- `LocationTable::check()` validation: required title, auto-alias, lat/lng range, timestamps
- `LocationsModel::populateState()` for filter persistence
- Search filter across title, city, state, address
- Published state filter and sort ordering support
- Filter form XML (`filter_locations.xml`) with search tools bar
- Language strings for filters, sort options, and save messages
- Site frontend `DisplayController` routing to list and detail views
- Site `LocationsModel` — published locations with search, city, and state filters
- Site `LocationModel` — single location by ID (published only)
- Site locations list view with Schema.org `LocalBusiness` markup and pagination
- Site location detail view with address, contact, hours, and map placeholder
- SEF URL router (`Service\Router`) with menu/standard/nomenu rules
- Menu item types: "All Locations" list and "Location Detail" with location picker
- Site language strings for frontend views and menu items
- Router registered in service provider and component extension class
- Map module dispatcher loads published locations with coordinates from DB
- Leaflet.js/OpenStreetMap integration with markers, popups, and auto-fit bounds
- Leaflet CSS/JS loaded via Joomla Web Asset Manager (`registerAndUseStyle`/`registerAndUseScript`)
- Search module dispatcher loads distinct cities/states and builds radius options
- City dropdown filter on search form (populated from DB, toggled by module param)
- Radius dropdown filter with configurable distance values and unit (miles/km)
- Geolocation "Use My Location" button with browser geolocation API
- Hidden lat/lng fields passed to component for proximity search
- Language strings for search module (city, radius, geolocation states)
## [Unreleased]
### Removed
- Makefile (no longer used)
- deploy-manual.yml workflow
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
### Previous (scaffold)
- Initial package scaffold with component, map module, and search module
- Database schema for locations table with coordinates
- Admin MVC skeleton for location CRUD
- Map module with Leaflet/Google Maps provider support (stub)
- Search module with city and radius filter options (stub)
### Added
- **Package** with component, 2 modules, and web services API plugin
- **Component (com_mokojoomstorelocator)**
- Admin location CRUD with tabbed edit form (details, address, coordinates, contact, media)
- Leaflet coordinate picker — click map to set lat/lng on admin form
- Locations list with publish/unpublish/delete, search, pagination
- Categories admin with parent/child hierarchy, color, and custom marker icon
- Multi-category support — locations assigned to multiple categories via junction table
- CSV import with 3-step wizard (upload, column mapping, preview/validate)
- CSV export with filter support and UTF-8 BOM for Excel
- Sample data injection (8 Tennessee locations with real coordinates)
- Geocoding service — Nominatim (free default) and Google Geocoding API
- Auto-geocode on save when address present but coordinates missing
- Video URL field with YouTube/Vimeo embed support
- Multiple images field (gallery) per location
- Contact form per location with email delivery and captcha
- Custom fields integration via Joomla com_fields (location + category contexts)
- Component config: geocoding provider, Google API key, auto-geocode toggle
- access.xml with component and category-level ACL permissions
- SQL update schema with versioned migration files
- Filter forms: filter_locations.xml, filter_categories.xml
- populateState for persistent admin list filters
- joomla.asset.json for Web Asset Manager (Leaflet, MarkerCluster, CSS)
- **Frontend (site views)**
- Locations list with linked titles, distance display, Get Directions buttons
- Single location detail page with embedded map, Schema.org JSON-LD
- Category view — locations filtered by category with color swatch
- SEF URL router (locations, location by alias, category by alias)
- Contact form embedded on location detail page
- Photo gallery and responsive video embed on detail page
- Print button with print stylesheet and static map image
- Category tags with color badges and links
- Responsive CSS with mobile-first grid, click-to-call phone
- **Map Module (mod_mokojoomstorelocator_map)**
- Leaflet.js with OpenStreetMap tiles (no API key required)
- Google Maps provider (optional, with API key)
- MarkerCluster plugin for both providers
- Category-colored SVG markers with custom icon override
- Category legend below map
- DOM-based popup content (XSS-safe)
- Auto-fit bounds to show all markers
- Get Directions link in popups
- **Search Module (mod_mokojoomstorelocator_search)**
- Text search (title, address, city, postcode)
- City dropdown filter from distinct values
- Radius filter with configurable options (miles/km)
- "Use My Location" geolocation button with permission handling
- Haversine formula for distance calculation in SQL
- **Web Services API (plg_webservices_mokojoomstorelocator)**
- CRUD routes for /v1/storelocator/locations
- CRUD routes for /v1/storelocator/categories
- Custom search route: /v1/storelocator/search
- JsonapiView for Locations and Categories
- **Package installer**
- Auto-enables modules and API plugin on install
- PHP 8.1+ and Joomla 4.4+ version checks
- en-GB and en-US language files for all extensions
### Fixed
- Hardcode name and description in all XML manifests (language variables don't resolve during install)
+24 -16
View File
@@ -4,29 +4,37 @@ This file provides guidance to Claude Code when working with this repository.
## Project Overview
**MokoSuiteStoreLocator** -- A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
**MokoJoomStoreLocator** -- A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
| Field | Value |
|---|---|
| **Platform** | joomla |
| **Extension type** | package (component + modules) |
| **Element** | `pkg_mokosuitestorelocator` |
| **Element** | `pkg_mokojoomstorelocator` |
| **Language** | PHP |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Wiki** | [MokoSuiteStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/wiki) |
| **Wiki** | [MokoJoomStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/wiki) |
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
## Package Contents
| Extension | Type | Element |
|---|---|---|
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Store Locator Component | component | `com_mokojoomstorelocator` |
| Store Locator Map | module (site) | `mod_mokojoomstorelocator_map` |
| Store Locator Search | module (site) | `mod_mokojoomstorelocator_search` |
## Common Commands
```bash
make build # Build package ZIP containing all sub-extensions
make lint # Run PHP linter
make validate # Lint + validation checks
make release # Validate + build
make clean # Clean build artifacts
```
```bash
composer install # Install PHP dev dependencies
```
@@ -35,18 +43,19 @@ composer install # Install PHP dev dependencies
This is a Joomla package. Key layout:
- `source/pkg_mokosuitestorelocator.xml` -- package manifest
- `source/script.php` -- package install/upgrade/uninstall script
- `source/packages/com_mokosuitestorelocator/` -- main component
- `src/pkg_mokojoomstorelocator.xml` -- package manifest
- `src/script.php` -- package install/upgrade/uninstall script
- `src/packages/com_mokojoomstorelocator/` -- main component
- `admin/` -- admin MVC (controllers, models, views, forms, tables, SQL)
- `site/` -- frontend MVC (controllers, models, views, templates)
- `mokosuitestorelocator.xml` -- component manifest
- `source/packages/mod_mokosuitestorelocator_map/` -- map display module
- `source/packages/mod_mokosuitestorelocator_search/` -- search/filter module
- `mokojoomstorelocator.xml` -- component manifest
- `src/packages/mod_mokojoomstorelocator_map/` -- map display module
- `src/packages/mod_mokojoomstorelocator_search/` -- search/filter module
- `updates.xml` -- Joomla update server manifest
## Database Table
`#__mokosuitestorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
`#__mokojoomstorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
## Rules
@@ -57,7 +66,6 @@ This is a Joomla package. Key layout:
- **Branch strategy**: develop on `dev/`, merge to `main` for release
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
- **PHP minimum**: 8.2
- **Joomla minimum**: 5.0
- **PHP minimum**: 8.1
- **Joomla table operations**: always use bind() -> check() -> store(), never save()
- **Namespace**: `Moko\Component\MokoSuiteStoreLocator` for the component
- **Namespace**: `Moko\Component\MokoJoomStoreLocator` for the component
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO:
VERSION: 01.00.01
VERSION: 04.04.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+128 -161
View File
@@ -1,161 +1,128 @@
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: {{DEFGROUP}}
INGROUP: Project.Documentation
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
VERSION: 04.04.00
PATH: ./CONTRIBUTING.md
BRIEF: How to contribute; branch strategy, commit conventions, PR workflow, and release pipeline
-->
# Contributing
Thank you for your interest in contributing to **MokoJoomTOS**!
This repository is governed by **[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)** — the authoritative source of coding standards, workflows, and policies for all Moko Consulting repositories.
## Branch Strategy
| Branch | Purpose | Deploys To |
|--------|---------|------------|
| `main` | Bleeding edge — all development merges here | CI only |
| `dev/XX.YY.ZZ` | Feature development | Dev server (version: "development") |
| `version/XX.YY` | Stable frozen snapshot | Demo + RS servers |
### Development Workflow
```
1. Create branch: git checkout -b dev/XX.YY.ZZ/my-feature
2. Develop + test (dev server auto-deploys on push)
3. Open PR → main (squash merge only)
4. Auto-release (version branch + tag + GitHub Release created automatically)
```
### Branch Naming
| Prefix | Use |
|--------|-----|
| `dev/XX.YY.ZZ` | Feature development (e.g., `dev/02.00.00/add-extrafields`) |
| `version/XX.YY` | Stable release (auto-created, never manually pushed) |
| `chore/` | Automated sync branches (managed by MokoStandards) |
> **Never use** `feature/`, `hotfix/`, or `release/` prefixes — they are not part of the MokoStandards branch strategy.
## Commit Conventions
Use [conventional commits](https://www.conventionalcommits.org/):
```
feat(scope): add new extrafield for invoice tracking
fix(sql): correct column type in llx_mytable
docs(readme): update installation instructions
chore(deps): bump enterprise library to 04.02.30
```
**Valid types:** `feat` | `fix` | `docs` | `chore` | `ci` | `refactor` | `style` | `test` | `perf` | `revert` | `build`
## Pull Request Workflow
1. **Branch** from `main` using `dev/XX.YY.ZZ/description` format
2. **Bump** the patch version in `README.md` before opening the PR
3. **Title** must be a valid conventional commit subject line
4. **Target** `main` — squash merge only (merge commits are disabled)
5. **CI checks** must pass before merge
### What Happens on Merge
When your PR is merged to `main`, these workflows run automatically:
1. **sync-version-on-merge** — auto-bumps patch version, propagates to all file headers
2. **auto-release** — creates `version/XX.YY` branch, git tag, and GitHub Release
3. **deploy-demo / deploy-rs** — deploys to demo and RS servers (if `src/**` changed)
## Coding Standards
All contributions must follow [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards):
| Standard | Reference |
|----------|-----------|
| Coding Style | [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) |
| File Headers | [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) |
| Branching | [branch-release-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branch-release-strategy.md) |
| Merge Strategy | [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) |
| Scripting | [scripting-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/scripting-standards.md) |
| Build & Release | [build-release.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/build-release.md) |
## PR Checklist
- [ ] Branch named `dev/XX.YY.ZZ/description`
- [ ] Patch version bumped in `README.md`
- [ ] Conventional commit format for PR title
- [ ] All new files have FILE INFORMATION headers
- [ ] `declare(strict_types=1)` in all PHP files
- [ ] PHPDoc on all public methods
- [ ] Tests pass
- [ ] CHANGELOG.md updated
- [ ] No secrets, tokens, or credentials committed
## Custom Workflows
Place repo-specific workflows in `.github/workflows/custom/` — they are **never overwritten or deleted** by MokoStandards sync:
```
.github/workflows/
├── deploy-dev.yml ← Synced from MokoStandards
├── auto-release.yml ← Synced from MokoStandards
└── custom/ ← Your custom workflows (safe)
└── my-custom-ci.yml
```
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0-or-later](LICENSE) license.
---
*This file is synced from [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Do not edit directly — changes will be overwritten on the next sync.*
+135
View File
@@ -0,0 +1,135 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This is a reference Makefile for building Joomla extensions.
# Copy this to your repository root as "Makefile" and customize as needed.
#
# Supports: Modules, Plugins, Components, Packages, Templates
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokojoomstorelocator
EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
# Module Configuration (for modules only)
MODULE_TYPE := site
# Options: site, admin
# Plugin Configuration (for plugins only)
PLUGIN_GROUP := system
# Options: system, content, user, authentication, etc.
# Directories
SRC_DIR := .
BUILD_DIR := build
DIST_DIR := dist
DOCS_DIR := docs
# Joomla Installation (for local testing - customize paths)
JOOMLA_ROOT := /var/www/html/joomla
JOOMLA_VERSION := 5
# Tools
PHP := php
COMPOSER := composer
NPM := npm
PHPCS := vendor/bin/phpcs
PHPCBF := vendor/bin/phpcbf
PHPUNIT := vendor/bin/phpunit
ZIP := zip
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
.PHONY: lint
lint: ## Run PHP linter (syntax check)
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: validate
validate: lint ## Run all validation checks
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
.PHONY: clean
clean: ## Clean build artifacts
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "Minifying assets..."
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "No minify script found"; \
fi
.PHONY: build
build: clean validate ## Build package ZIP containing all sub-extensions
@echo "$(COLOR_BLUE)Building Joomla package...$(COLOR_RESET)"
@mkdir -p $(DIST_DIR) $(BUILD_DIR)/pkg_$(EXTENSION_NAME)
@# Build each sub-extension into its own ZIP
@for ext in src/packages/*/; do \
EXT_NAME=$$(basename $$ext); \
echo " Packaging $$EXT_NAME..."; \
mkdir -p $(BUILD_DIR)/$$EXT_NAME; \
rsync -a --exclude='.git*' "$$ext" "$(BUILD_DIR)/$$EXT_NAME/"; \
cd $(BUILD_DIR) && $(ZIP) -r "pkg_$(EXTENSION_NAME)/$$EXT_NAME.zip" "$$EXT_NAME" && cd ..; \
done
@# Copy the package manifest
@cp src/pkg_mokojoomstorelocator.xml $(BUILD_DIR)/pkg_$(EXTENSION_NAME)/
@if [ -f "src/script.php" ]; then cp src/script.php $(BUILD_DIR)/pkg_$(EXTENSION_NAME)/; fi
@# Create the final package ZIP
@cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip" "pkg_$(EXTENSION_NAME)"
@echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
.PHONY: release
release: validate build ## Create a release (validate + build)
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
.PHONY: version
version: ## Display version information
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
@echo " Name: $(EXTENSION_NAME)"
@echo " Type: $(EXTENSION_TYPE)"
@echo " Version: $(EXTENSION_VERSION)"
# Default target
.DEFAULT_GOAL := help
+15 -38
View File
@@ -1,57 +1,34 @@
# MokoSuiteStoreLocator
# MokoJoomStoreLocator
A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
## Package Contents
| Extension | Type | Element |
|---|---|---|
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Extension | Description |
|---|---|
| `com_mokojoomstorelocator` | Component for managing store locations (admin CRUD + frontend listing) |
| `mod_mokojoomstorelocator_map` | Site module displaying an interactive map with location markers |
| `mod_mokojoomstorelocator_search` | Site module providing search/filter form for finding locations |
## Requirements
- Joomla 5.x or 6.x
- PHP 8.2+
- MySQL 8.0+ / MariaDB 10.4+
- Joomla 4.4+ or 5.x
- PHP 8.1+
- MySQL 5.7+ / MariaDB 10.3+
## Installation
1. Download the latest `pkg_mokosuitestorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/releases)
1. Download the latest `pkg_mokojoomstorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/releases)
2. In Joomla Administrator, go to **System > Install > Extensions**
3. Upload the package ZIP — all extensions install automatically
## Features
### Implemented
- **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image)
- **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete
- **Site frontend** — locations list and detail views with pagination
- **Schema.org** — LocalBusiness structured data markup on all frontend templates
- **SEF URLs** — router with menu, standard, and nomenu rules
- **Menu items** — "All Locations" list and single "Location Detail" picker
- **Interactive map** — Leaflet.js with OpenStreetMap tiles, markers with popups, auto-fit bounds
- **Location search** — city dropdown, radius filter, and browser geolocation ("Use My Location")
- **Proximity search** — Haversine distance filtering with distance-sorted results
- **Get Directions** — Google Maps directions link on detail page and map popups
- **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM)
- **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping
### Planned
- Marker clustering for dense location areas
- Multi-category support with custom map markers
- ACL permissions and SQL upgrade schema
- REST API via Joomla Web Services plugin
- MokoSuiteShop integration for multi-store ecommerce
## Development
```bash
composer install # Install PHP dev dependencies
```
Source code lives in `source/packages/` — one directory per sub-extension.
- Manage store locations with address, coordinates, contact info, and business hours
- Interactive map display (OpenStreetMap/Leaflet or Google Maps)
- Location search by city, postcode, or radius
- Schema.org LocalBusiness structured data markup
- Category support for grouping locations
## License
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 01.00.01
VERSION: 04.04.01
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field
name="search"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL"
hint="JSEARCH_FILTER"
inputmode="search"
/>
<field
name="published"
type="status"
label="JOPTION_SELECT_PUBLISHED"
onchange="this.form.submit();"
>
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
</fields>
<fields name="list">
<field
name="fullordering"
type="list"
label="JGLOBAL_SORT_BY"
default="a.title ASC"
onchange="this.form.submit();"
>
<option value="a.title ASC">JGLOBAL_TITLE_ASC</option>
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
<option value="a.city ASC">COM_MOKOJOOMSTORELOCATOR_CITY_ASC</option>
<option value="a.city DESC">COM_MOKOJOOMSTORELOCATOR_CITY_DESC</option>
<option value="a.published ASC">JSTATUS_ASC</option>
<option value="a.published DESC">JSTATUS_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
</field>
<field
name="limit"
type="limitbox"
label="JGLOBAL_LIST_LIMIT"
default="25"
onchange="this.form.submit();"
/>
</fields>
</form>
@@ -1,61 +0,0 @@
; MokoSuiteStoreLocator - Admin language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
COM_MOKOJOOMSTORELOCATOR_CITY="City"
COM_MOKOJOOMSTORELOCATOR_STATE="State"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL="Search Locations"
COM_MOKOJOOMSTORELOCATOR_CITY_ASC="City ascending"
COM_MOKOJOOMSTORELOCATOR_CITY_DESC="City descending"
COM_MOKOJOOMSTORELOCATOR_LOCATION_SAVE_SUCCESS="Location saved successfully."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_PUBLISHED="%d location(s) published."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_UNPUBLISHED="%d location(s) unpublished."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_DELETED="%d location(s) deleted."
COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED="A location title is required."
COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE="Latitude must be between -90 and 90."
COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE="Longitude must be between -180 and 180."
COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS="Coordinates were auto-populated from the address via OpenStreetMap."
COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED="Geocoding failed: %s. You can enter coordinates manually."
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_DESC="Import store locations from a CSV file."
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPLOAD="Upload CSV File"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Select a CSV file with location data. First row must be column headers."
COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER="Delimiter"
COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER_DESC="The character separating fields in your CSV file."
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS="%d location(s) imported successfully."
COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED="%d row(s) skipped due to errors."
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE="The uploaded file is not a valid CSV."
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_ROWS="The CSV file contains no data rows."
COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required."
@@ -1,87 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/**
* Import controller for CSV location uploads.
*
* @since 1.1.0
*/
class ImportController extends BaseController
{
/**
* Process the uploaded CSV file.
*
* @return void
*
* @since 1.1.0
*/
public function import(): void
{
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
// ACL check — user must have create permission
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokosuitestorelocator'))
{
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
return;
}
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Model\ImportModel $model */
$model = $this->getModel('Import', 'Administrator');
$file = $this->input->files->get('jform', [], 'array');
$delimiter = $this->input->post->getString('delimiter', ',');
$csvFile = $file['csv_file'] ?? null;
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
// Validate file extension
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv' && $ext !== 'txt')
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
$result = $model->processImport($csvFile['tmp_name'], $delimiter);
if ($result['imported'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS', $result['imported']));
}
if ($result['skipped'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
}
}
@@ -1,45 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Controller for a single location form.
*
* @since 1.0.0
*/
class LocationController extends FormController
{
/**
* The prefix to use with controller messages.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
/**
* The view list to redirect to after save.
*
* @var string
* @since 1.0.0
*/
protected $view_list = 'locations';
/**
* The view item for edit.
*
* @var string
* @since 1.0.0
*/
protected $view_item = 'location';
}
@@ -1,25 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Extension\MVCComponent;
/**
* Component class for com_mokosuitestorelocator.
*
* @since 1.0.0
*/
class MokoSuiteStoreLocatorComponent extends MVCComponent implements RouterServiceInterface
{
use RouterServiceTrait;
}
@@ -1,214 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use SplFileObject;
/**
* Import model for CSV location processing.
*
* @since 1.1.0
*/
class ImportModel extends BaseDatabaseModel
{
/**
* Known CSV column names mapped to database fields.
*
* @var array
* @since 1.1.0
*/
private const COLUMN_MAP = [
'title' => 'title',
'name' => 'title',
'store' => 'title',
'location' => 'title',
'description' => 'description',
'address' => 'address',
'street' => 'address',
'city' => 'city',
'state' => 'state',
'province' => 'state',
'region' => 'state',
'postcode' => 'postcode',
'zip' => 'postcode',
'zipcode' => 'postcode',
'postal_code' => 'postcode',
'country' => 'country',
'latitude' => 'latitude',
'lat' => 'latitude',
'longitude' => 'longitude',
'lng' => 'longitude',
'lon' => 'longitude',
'phone' => 'phone',
'telephone' => 'phone',
'email' => 'email',
'website' => 'website',
'url' => 'website',
'hours' => 'hours',
'image' => 'image',
'published' => 'published',
];
/**
* Process a CSV file and import locations.
*
* @param string $filePath Path to the uploaded CSV file.
* @param string $delimiter CSV delimiter character.
*
* @return array ['imported' => int, 'skipped' => int, 'errors' => array]
*
* @since 1.1.0
*/
public function processImport(string $filePath, string $delimiter = ','): array
{
$result = ['imported' => 0, 'skipped' => 0, 'errors' => []];
$file = new SplFileObject($filePath, 'r');
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$file->setCsvControl($delimiter);
// Read and map headers (strip UTF-8 BOM from Excel exports)
$headers = $file->fgetcsv();
if (!empty($headers[0]))
{
$headers[0] = ltrim($headers[0], "\xEF\xBB\xBF");
}
if (!$headers || \count($headers) < 2)
{
$result['errors'][] = 'Invalid CSV headers.';
return $result;
}
$mapping = $this->mapColumns($headers);
if (!isset($mapping['title']))
{
$result['errors'][] = 'CSV must contain a "title" or "name" column.';
return $result;
}
$db = $this->getDatabase();
$table = $this->getMVCFactory()->createTable('Location', 'Administrator');
$user = Factory::getApplication()->getIdentity();
$rowNum = 1;
foreach ($file as $row)
{
$rowNum++;
if (empty($row) || (\count($row) === 1 && $row[0] === null))
{
continue;
}
$data = $this->mapRowToData($row, $headers, $mapping);
if (empty($data['title']))
{
$result['errors'][] = "Row $rowNum: missing title";
$result['skipped']++;
continue;
}
$data['published'] = (int) ($data['published'] ?? 1);
$data['created_by'] = $user->id;
// Reset table state for each row
$table->reset();
$table->id = 0;
if (!$table->bind($data))
{
$result['errors'][] = "Row $rowNum: " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->check())
{
$result['errors'][] = "Row $rowNum: " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->store())
{
$result['errors'][] = "Row $rowNum: " . $table->getError();
$result['skipped']++;
continue;
}
$result['imported']++;
}
return $result;
}
/**
* Auto-map CSV headers to database field names.
*
* @param array $headers CSV column headers.
*
* @return array Associative array of db_field => csv_index.
*
* @since 1.1.0
*/
private function mapColumns(array $headers): array
{
$mapping = [];
foreach ($headers as $index => $header)
{
$normalized = strtolower(trim(str_replace([' ', '-', '_'], ['_', '_', '_'], $header)));
if (isset(self::COLUMN_MAP[$normalized]))
{
$dbField = self::COLUMN_MAP[$normalized];
if (!isset($mapping[$dbField]))
{
$mapping[$dbField] = $index;
}
}
}
return $mapping;
}
/**
* Map a CSV row to a data array using the column mapping.
*
* @param array $row CSV row values.
* @param array $headers CSV column headers.
* @param array $mapping Column mapping (db_field => csv_index).
*
* @return array Data array ready for table bind.
*
* @since 1.1.0
*/
private function mapRowToData(array $row, array $headers, array $mapping): array
{
$data = [];
foreach ($mapping as $dbField => $csvIndex)
{
$data[$dbField] = trim($row[$csvIndex] ?? '');
}
return $data;
}
}
@@ -1,183 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
/**
* Single location edit model.
*
* @since 1.0.0
*/
class LocationModel extends AdminModel
{
/**
* The type alias for this content type.
*
* @var string
* @since 1.0.0
*/
public $typeAlias = 'com_mokosuitestorelocator.location';
/**
* Get the form for this model.
*
* @param array $data Data for the form.
* @param boolean $loadData True if the form is to load its own data.
*
* @return Form|boolean A Form object on success, false on failure.
*
* @since 1.0.0
*/
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokosuitestorelocator.location',
'location',
['control' => 'jform', 'load_data' => $loadData]
);
if (empty($form))
{
return false;
}
return $form;
}
/**
* Load the data for the form.
*
* @return mixed The data for the form.
*
* @since 1.0.0
*/
protected function loadFormData()
{
$data = $this->getItem();
return $data;
}
/**
* Get the table for this model.
*
* @param string $name The table name.
* @param string $prefix The table prefix.
* @param array $options Configuration array for the table.
*
* @return Table
*
* @since 1.0.0
*/
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
/**
* Save the location, auto-geocoding the address if coordinates are empty.
*
* @param array $data The form data.
*
* @return boolean True on success.
*
* @since 1.1.0
*/
public function save($data)
{
$hasCoords = isset($data['latitude'], $data['longitude'])
&& is_numeric($data['latitude']) && is_numeric($data['longitude']);
$hasAddress = !empty($data['address']) || !empty($data['city']) || !empty($data['postcode']);
if (!$hasCoords && $hasAddress)
{
$coords = $this->geocodeAddress($data);
if ($coords)
{
$data['latitude'] = $coords['lat'];
$data['longitude'] = $coords['lng'];
Factory::getApplication()->enqueueMessage(
Text::_('COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS'),
'success'
);
}
}
return parent::save($data);
}
/**
* Geocode an address using the Nominatim (OpenStreetMap) API.
*
* @param array $data Location data with address fields.
*
* @return array|null ['lat' => float, 'lng' => float] or null on failure.
*
* @since 1.1.0
*/
private function geocodeAddress(array $data): ?array
{
$parts = array_filter([
$data['address'] ?? '',
$data['city'] ?? '',
$data['state'] ?? '',
$data['postcode'] ?? '',
$data['country'] ?? '',
]);
if (empty($parts))
{
return null;
}
$query = implode(', ', $parts);
try
{
$http = HttpFactory::getHttp();
$url = 'https://nominatim.openstreetmap.org/search?'
. http_build_query(['format' => 'json', 'limit' => 1, 'q' => $query]);
$response = $http->get($url, ['User-Agent' => 'MokoSuiteStoreLocator/1.1 (+https://mokoconsulting.tech)'], 10);
if ($response->code !== 200)
{
return null;
}
$results = json_decode($response->body, true);
if (isset($results[0]['lat']) && is_numeric($results[0]['lat'])
&& isset($results[0]['lon']) && is_numeric($results[0]['lon']))
{
return [
'lat' => round((float) $results[0]['lat'], 8),
'lng' => round((float) $results[0]['lon'], 8),
];
}
}
catch (\Exception $e)
{
Factory::getApplication()->enqueueMessage(
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED', $e->getMessage()),
'warning'
);
}
return null;
}
}
@@ -1,98 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Location table class.
*
* @since 1.0.0
*/
class LocationTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver object.
*
* @since 1.0.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokosuitestorelocator_locations', 'id', $db);
$this->setColumnAlias('published', 'published');
}
/**
* Overloaded check method to ensure data integrity.
*
* @return boolean True if the data is valid.
*
* @since 1.0.0
*/
public function check(): bool
{
if (trim($this->title) === '')
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED'));
return false;
}
if (trim($this->alias) === '')
{
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90))
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE'));
return false;
}
if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180))
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE'));
return false;
}
$now = Factory::getDate()->toSql();
$user = Factory::getApplication()->getIdentity();
if (!(int) $this->id)
{
if (!$this->created || $this->created === '0000-00-00 00:00:00')
{
$this->created = $now;
}
if (!$this->created_by)
{
$this->created_by = $user->id;
}
}
$this->modified = $now;
$this->modified_by = $user->id;
return parent::check();
}
}
@@ -1,88 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Import\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=import.import'); ?>"
method="post" enctype="multipart/form-data" class="form-validate">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPLOAD'); ?></h3>
<p class="text-muted"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC'); ?></p>
<div class="mb-3">
<label for="csv_file" class="form-label">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?>
</label>
<input type="file" name="jform[csv_file]" id="csv_file"
class="form-control" accept=".csv,text/csv" required />
</div>
<div class="mb-3">
<label for="delimiter" class="form-label">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER'); ?>
</label>
<select name="delimiter" id="delimiter" class="form-select" style="width: auto;">
<option value=","><?php echo Text::_('Comma (,)'); ?></option>
<option value=";"><?php echo Text::_('Semicolon (;)'); ?></option>
<option value="|"><?php echo Text::_('Pipe (|)'); ?></option>
<option value="&#9;"><?php echo Text::_('Tab'); ?></option>
</select>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT'); ?>
</button>
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
class="btn btn-secondary ms-2">
<?php echo Text::_('JCANCEL'); ?>
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h4><?php echo Text::_('JHELP'); ?></h4>
<p>Supported column headers (auto-detected):</p>
<ul class="small">
<li><strong>title</strong> / name / store / location (required)</li>
<li>address / street</li>
<li>city</li>
<li>state / province / region</li>
<li>postcode / zip / zipcode / postal_code</li>
<li>country</li>
<li>latitude / lat</li>
<li>longitude / lng / lon</li>
<li>phone / telephone</li>
<li>email</li>
<li>website / url</li>
<li>hours</li>
<li>published (0 or 1)</li>
</ul>
</div>
</div>
</div>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -1,73 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Location\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('title'); ?>
<?php echo $this->form->renderField('alias'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
<?php echo $this->form->renderField('image'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('address'); ?>
<?php echo $this->form->renderField('city'); ?>
<?php echo $this->form->renderField('state'); ?>
<?php echo $this->form->renderField('postcode'); ?>
<?php echo $this->form->renderField('country'); ?>
</div>
<div class="col-lg-6">
<?php echo $this->form->renderField('latitude'); ?>
<?php echo $this->form->renderField('longitude'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'contact', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('phone'); ?>
<?php echo $this->form->renderField('email'); ?>
<?php echo $this->form->renderField('website'); ?>
</div>
<div class="col-lg-6">
<?php echo $this->form->renderField('hours'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- =========================================================================
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
=========================================================================
FILE INFORMATION
DEFGROUP: MokoSuiteStoreLocator
INGROUP: com_mokosuitestorelocator
PATH: src/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml
VERSION: 01.00.00
BRIEF: Component manifest for the store locator component
=========================================================================
-->
<extension type="component" method="upgrade">
<name>com_mokosuitestorelocator</name>
<version>01.00.01</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>COM_MOKOJOOMSTORELOCATOR_DESC</description>
<namespace path="src">Moko\Component\MokoSuiteStoreLocator</namespace>
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
</sql>
</install>
<uninstall>
<sql>
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
<files folder="site">
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<administration>
<files folder="admin">
<folder>forms</folder>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu>
<menu link="option=com_mokosuitestorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu>
</administration>
</extension>
@@ -1,20 +0,0 @@
; MokoSuiteStoreLocator - Site language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE="All Locations"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC="Displays a list of all store locations."
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE="Location Detail"
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC="Displays a single store location with full details."
COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION="Select Location"
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
@@ -1,81 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\Database\ParameterType;
/**
* Single location model for the site frontend.
*
* @since 1.0.0
*/
class LocationModel extends ItemModel
{
/**
* The location item.
*
* @var object|null
* @since 1.0.0
*/
protected $_item = null;
/**
* Get a single location item.
*
* @param integer $pk The item primary key. If null, uses the model state.
*
* @return object|null The location object or null if not found.
*
* @since 1.0.0
*/
public function getItem($pk = null)
{
$pk = $pk ?: (int) $this->getState('location.id');
if ($this->_item === null)
{
$this->_item = [];
}
if (!isset($this->_item[$pk]))
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.id') . ' = :pk')
->where($db->quoteName('a.published') . ' = 1')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$this->_item[$pk] = $db->loadObject();
}
return $this->_item[$pk] ?? null;
}
/**
* Populate the model state.
*
* @return void
*
* @since 1.0.0
*/
protected function populateState()
{
$app = $this->getApplication();
$id = $app->input->getInt('id', 0);
$this->setState('location.id', $id);
}
}
@@ -1,190 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
/**
* Locations list model for the site frontend.
*
* @since 1.0.0
*/
class LocationsModel extends ListModel
{
/**
* Constructor.
*
* @param array $config Configuration settings.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'city', 'a.city',
'state', 'a.state',
'ordering', 'a.ordering',
];
}
parent::__construct($config);
}
/**
* Populate the model state.
*
* @param string $ordering Default ordering column.
* @param string $direction Default ordering direction.
*
* @return void
*
* @since 1.0.0
*/
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
{
$app = $this->getApplication();
$search = $app->input->getString('search', '');
$this->setState('filter.search', $search);
$city = $app->input->getString('city', '');
$this->setState('filter.city', $city);
$state = $app->input->getString('state', '');
$this->setState('filter.state', $state);
$latRaw = $app->input->getString('lat', '');
$lngRaw = $app->input->getString('lng', '');
$radius = $app->input->getInt('radius', 0);
$radiusUnit = $app->input->getString('radius_unit', 'miles');
if ($latRaw !== '' && is_numeric($latRaw))
{
$lat = (float) $latRaw;
if ($lat >= -90 && $lat <= 90)
{
$this->setState('filter.lat', $lat);
}
}
if ($lngRaw !== '' && is_numeric($lngRaw))
{
$lng = (float) $lngRaw;
if ($lng >= -180 && $lng <= 180)
{
$this->setState('filter.lng', $lng);
}
}
if ($radius > 0 && $radius <= 25000)
{
$this->setState('filter.radius', $radius);
}
$this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles');
parent::populateState($ordering, $direction);
}
/**
* Build the query for the locations list.
*
* @return QueryInterface
*
* @since 1.0.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.published') . ' = 1');
// Search filter
$search = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where(
'(' . $db->quoteName('a.title') . ' LIKE :search'
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
. ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
)
->bind(':search', $search)
->bind(':search2', $search)
->bind(':search3', $search)
->bind(':search4', $search);
}
// City filter
$city = $this->getState('filter.city');
if (!empty($city))
{
$query->where($db->quoteName('a.city') . ' = :city')
->bind(':city', $city);
}
// State filter
$state = $this->getState('filter.state');
if (!empty($state))
{
$query->where($db->quoteName('a.state') . ' = :state')
->bind(':state', $state);
}
// Proximity / Haversine distance filter
$lat = $this->getState('filter.lat');
$lng = $this->getState('filter.lng');
$radius = $this->getState('filter.radius');
if ($lat !== null && $lng !== null && $radius)
{
$unit = $this->getState('filter.radius_unit', 'miles');
$earthRadius = ($unit === 'km') ? 6371 : 3959;
$haversine = '(' . $earthRadius . ' * ACOS(LEAST(1, GREATEST(-1, '
. 'SIN(RADIANS(' . $db->quoteName('a.latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) '
. '+ COS(RADIANS(' . $db->quoteName('a.latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) '
. '* COS(RADIANS(' . $db->quoteName('a.longitude') . ' - ' . (float) $lng . '))'
. '))))';
$query->where($db->quoteName('a.latitude') . ' IS NOT NULL')
->where($db->quoteName('a.longitude') . ' IS NOT NULL')
->where($haversine . ' <= ' . (int) $radius);
$query->select($haversine . ' AS distance');
$query->order('distance ASC');
}
else
{
// Default ordering
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
}
return $query;
}
}
@@ -1,51 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
/**
* SEF URL router for the store locator component.
*
* @since 1.0.0
*/
class Router extends RouterView
{
/**
* Constructor.
*
* @param SiteApplication $app The application object.
* @param AbstractMenu $menu The menu object.
*
* @since 1.0.0
*/
public function __construct(SiteApplication $app, AbstractMenu $menu)
{
$locations = new RouterViewConfiguration('locations');
$this->registerView($locations);
$location = new RouterViewConfiguration('location');
$location->setKey('id')->setParent($locations);
$this->registerView($location);
parent::__construct($app, $menu);
$this->attachRule(new MenuRules($this));
$this->attachRule(new StandardRules($this));
$this->attachRule(new NomenuRules($this));
}
}
@@ -1,48 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Single location detail view for the site frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var object|null
* @since 1.0.0
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->item = $this->get('Item');
if ($this->item === null)
{
throw new \Exception('Location not found', 404);
}
parent::display($tpl);
}
}
@@ -1,120 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Location\HtmlView $this */
$item = $this->item;
?>
<div class="com-mokosuitestorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<?php if ($item->image) : ?>
<div class="com-mokosuitestorelocator-location__image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image">
</div>
<?php endif; ?>
<?php if ($item->description) : ?>
<div class="com-mokosuitestorelocator-location__description" itemprop="description">
<?php echo HTMLHelper::_('content.prepare', $item->description); ?>
</div>
<?php endif; ?>
<div class="com-mokosuitestorelocator-location__details">
<div class="com-mokosuitestorelocator-location__address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS'); ?></h3>
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
<?php if ($item->country) : ?>
<br><span itemprop="addressCountry"><?php echo $this->escape($item->country); ?></span>
<?php endif; ?>
<?php if ($item->latitude && $item->longitude) : ?>
<div class="com-mokosuitestorelocator-location__directions mt-2">
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
class="btn btn-outline-primary btn-sm"
target="_blank" rel="noopener"
data-directions
data-lat="<?php echo (float) $item->latitude; ?>"
data-lng="<?php echo (float) $item->longitude; ?>"
data-title="<?php echo $this->escape($item->title); ?>">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
</a>
</div>
<?php endif; ?>
</div>
<div class="com-mokosuitestorelocator-location__contact">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h3>
<?php if ($item->phone) : ?>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE'); ?>:</strong>
<?php $safePhone = preg_replace('/[^0-9+\-() ]/', '', $item->phone); ?>
<a href="tel:<?php echo $this->escape($safePhone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->email) : ?>
<div>
<strong><?php echo Text::_('JGLOBAL_EMAIL'); ?>:</strong>
<a href="mailto:<?php echo $this->escape($item->email); ?>" itemprop="email">
<?php echo $this->escape($item->email); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->website && preg_match('#^https?://#i', $item->website)) : ?>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE'); ?>:</strong>
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
<?php echo $this->escape($item->website); ?>
</a>
</div>
<?php endif; ?>
</div>
<?php if ($item->hours) : ?>
<div class="com-mokosuitestorelocator-location__hours">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS'); ?></h3>
<div itemprop="openingHours">
<?php echo nl2br($this->escape($item->hours)); ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($item->latitude && $item->longitude) : ?>
<meta itemprop="latitude" content="<?php echo $this->escape($item->latitude); ?>">
<meta itemprop="longitude" content="<?php echo $this->escape($item->longitude); ?>">
<div class="com-mokosuitestorelocator-location__map"
data-lat="<?php echo $this->escape($item->latitude); ?>"
data-lng="<?php echo $this->escape($item->longitude); ?>"
data-title="<?php echo $this->escape($item->title); ?>"
style="height: 300px;">
</div>
<?php endif; ?>
</div>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC]]>
</message>
</layout>
<fields name="request">
<fieldset name="request">
<field
name="id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION"
query="SELECT id, title FROM #__mokosuitestorelocator_locations WHERE published = 1 ORDER BY title"
key_field="id"
value_field="title"
required="true"
/>
</fieldset>
</fields>
</metadata>
@@ -1,70 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Locations\HtmlView $this */
?>
<div class="com-mokosuitestorelocator-locations">
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS'); ?></p>
<?php else : ?>
<div class="com-mokosuitestorelocator-locations__list">
<?php foreach ($this->items as $item) : ?>
<div class="com-mokosuitestorelocator-location-card" itemscope itemtype="https://schema.org/LocalBusiness">
<?php if ($item->image) : ?>
<div class="com-mokosuitestorelocator-location-card__image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" loading="lazy">
</div>
<?php endif; ?>
<div class="com-mokosuitestorelocator-location-card__body">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=location&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<div itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
</div>
<?php if ($item->phone) : ?>
<div class="com-mokosuitestorelocator-location-card__phone">
<?php $safePhone = preg_replace('/[^0-9+\-() ]/', '', $item->phone); ?>
<a href="tel:<?php echo $this->escape($safePhone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
</div>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC]]>
</message>
</layout>
</metadata>
@@ -1,80 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_map
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoSuiteStoreLocatorMap\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
/**
* Dispatcher for mod_mokosuitestorelocator_map.
*
* @since 1.0.0
*/
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface, DatabaseAwareInterface
{
use HelperFactoryAwareTrait;
use DatabaseAwareTrait;
/**
* Returns the layout data.
*
* @return array
*
* @since 1.0.0
*/
protected function getLayoutData(): array
{
$data = parent::getLayoutData();
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select([
$db->quoteName('id'),
$db->quoteName('title'),
$db->quoteName('address'),
$db->quoteName('city'),
$db->quoteName('state'),
$db->quoteName('postcode'),
$db->quoteName('phone'),
$db->quoteName('latitude'),
$db->quoteName('longitude'),
])
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('longitude') . ' IS NOT NULL');
$db->setQuery($query);
$locations = $db->loadObjectList() ?: [];
$markers = [];
foreach ($locations as $loc)
{
$markers[] = [
'id' => (int) $loc->id,
'title' => $loc->title,
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
'phone' => $loc->phone,
'lat' => (float) $loc->latitude,
'lng' => (float) $loc->longitude,
];
}
$data['locations'] = $markers;
return $data;
}
}
@@ -1,83 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_map
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
/** @var array $displayData */
$params = $displayData['params'];
$locations = $displayData['locations'] ?? [];
$moduleId = $displayData['module']->id;
$mapHeight = $params->get('map_height', '400px');
$mapZoom = (int) $params->get('map_zoom', 10);
$provider = $params->get('map_provider', 'leaflet');
$apiKey = $params->get('api_key', '');
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $displayData['app']->getDocument()->getWebAssetManager();
if ($provider === 'leaflet')
{
$wa->registerAndUseStyle('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], ['integrity' => 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=', 'crossorigin' => '']);
$wa->registerAndUseScript('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', [], ['integrity' => 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', 'crossorigin' => '', 'defer' => true]);
}
?>
<div class="mod-mokosuitestorelocator-map"
id="mokosuitestorelocator-map-<?php echo (int) $moduleId; ?>"
style="height: <?php echo $this->escape($mapHeight); ?>;"
data-locations='<?php echo json_encode($locations, JSON_HEX_APOS | JSON_HEX_TAG); ?>'
data-zoom="<?php echo $mapZoom; ?>"
data-provider="<?php echo $this->escape($provider); ?>"
<?php if ($apiKey) : ?>data-api-key="<?php echo $this->escape($apiKey); ?>"<?php endif; ?>>
<noscript>
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
</noscript>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('mokosuitestorelocator-map-<?php echo (int) $moduleId; ?>');
if (!el || typeof L === 'undefined') return;
var locations = JSON.parse(el.getAttribute('data-locations') || '[]');
var zoom = parseInt(el.getAttribute('data-zoom') || '10', 10);
var map = L.map(el.id);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
if (locations.length === 0) {
map.setView([39.8283, -98.5795], 4);
return;
}
var bounds = L.latLngBounds();
function esc(str) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(str || ''));
return d.innerHTML;
}
locations.forEach(function(loc) {
var marker = L.marker([loc.lat, loc.lng]).addTo(map);
var popup = '<strong>' + esc(loc.title) + '</strong>';
if (loc.address) popup += '<br>' + esc(loc.address);
if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>';
popup += '<br><a href="https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng + '" target="_blank" rel="noopener"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true); ?></a>';
marker.bindPopup(popup);
bounds.extend([loc.lat, loc.lng]);
});
map.fitBounds(bounds, { padding: [30, 30], maxZoom: zoom });
});
</script>
@@ -1,121 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_search
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var array $displayData */
$params = $displayData['params'];
$cities = $displayData['cities'] ?? [];
$states = $displayData['states'] ?? [];
$radiusOptions = $displayData['radiusOptions'] ?? [];
$radiusUnit = $displayData['radiusUnit'] ?? 'miles';
$showCity = (int) $params->get('show_city_filter', 1);
$showRadius = (int) $params->get('show_radius_filter', 1);
$moduleId = $displayData['module']->id;
?>
<div class="mod-mokosuitestorelocator-search">
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
method="get" class="mokosuitestorelocator-search-form" id="mokosuitestorelocator-search-<?php echo (int) $moduleId; ?>">
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
</label>
<input type="text"
id="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>"
name="search"
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
class="form-control"
/>
</div>
<?php if ($showCity && !empty($cities)) : ?>
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_CITY'); ?>
</label>
<select id="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>"
name="city" class="form-select">
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ALL_CITIES'); ?></option>
<?php foreach ($cities as $city) : ?>
<option value="<?php echo $this->escape($city); ?>">
<?php echo $this->escape($city); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if ($showRadius && !empty($radiusOptions)) : ?>
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS'); ?>
</label>
<select id="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>"
name="radius" class="form-select">
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ANY_DISTANCE'); ?></option>
<?php foreach ($radiusOptions as $radius) : ?>
<option value="<?php echo (int) $radius; ?>">
<?php echo (int) $radius . ' ' . $this->escape($radiusUnit); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" name="lat" id="mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>" value="" />
<input type="hidden" name="lng" id="mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>" value="" />
<input type="hidden" name="radius_unit" value="<?php echo $this->escape($radiusUnit); ?>" />
<button type="button" class="btn btn-outline-secondary mokosuitestorelocator-geolocation-btn"
id="mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>
</button>
<?php endif; ?>
<button type="submit" class="btn btn-primary">
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
<input type="hidden" name="option" value="com_mokosuitestorelocator" />
<input type="hidden" name="view" value="locations" />
</form>
</div>
<?php if ($showRadius) : ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>');
if (!btn || !navigator.geolocation) {
if (btn) btn.style.display = 'none';
return;
}
btn.addEventListener('click', function() {
btn.disabled = true;
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING', true); ?>';
navigator.geolocation.getCurrentPosition(
function(pos) {
document.getElementById('mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>').value = pos.coords.latitude;
document.getElementById('mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>').value = pos.coords.longitude;
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_SET', true); ?>';
btn.disabled = false;
},
function() {
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION', true); ?>';
btn.disabled = false;
},
{ enableHighAccuracy: false, timeout: 10000 }
);
});
});
</script>
<?php endif; ?>
-42
View File
@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- =========================================================================
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
=========================================================================
FILE INFORMATION
DEFGROUP: MokoSuiteStoreLocator
INGROUP: pkg_mokosuitestorelocator
PATH: src/pkg_mokosuitestorelocator.xml
VERSION: 01.00.00
BRIEF: Package manifest for the MokoSuiteStoreLocator package
=========================================================================
-->
<extension type="package" method="upgrade">
<name>pkg_mokosuitestorelocator</name>
<packagename>mokosuitestorelocator</packagename>
<version>01.00.01</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<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>
<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>
</files>
<updateservers>
<server type="extension" name="MokoSuiteStoreLocator Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/updates.xml</server>
</updateservers>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
</extension>
-131
View File
@@ -1,131 +0,0 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage pkg_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Installer\InstallerScriptInterface;
use Joomla\CMS\Log\Log;
/**
* Package installation script for MokoSuiteStoreLocator.
*
* @since 1.0.0
*/
class Pkg_MokosuitestorelocatorInstallerScript implements InstallerScriptInterface
{
/**
* Minimum PHP version required.
*
* @var string
* @since 1.0.0
*/
protected string $minimumPhp = '8.2';
/**
* Minimum Joomla version required.
*
* @var string
* @since 1.0.0
*/
protected string $minimumJoomla = '5.0.0';
/**
* Called before any type of action.
*
* @param string $type Installation type (install, update, discover_install).
* @param InstallerAdapter $parent The parent installer object.
*
* @return boolean True on success.
*
* @since 1.0.0
*/
public function preflight(string $type, InstallerAdapter $parent): bool
{
if (version_compare(PHP_VERSION, $this->minimumPhp, '<'))
{
Log::add(
'MokoSuiteStoreLocator requires PHP ' . $this->minimumPhp . ' or later.',
Log::WARNING,
'jerror'
);
return false;
}
if (version_compare(JVERSION, $this->minimumJoomla, '<'))
{
Log::add(
'MokoSuiteStoreLocator requires Joomla ' . $this->minimumJoomla . ' or later.',
Log::WARNING,
'jerror'
);
return false;
}
return true;
}
/**
* Called on installation.
*
* @param InstallerAdapter $parent The parent installer object.
*
* @return boolean True on success.
*
* @since 1.0.0
*/
public function install(InstallerAdapter $parent): bool
{
return true;
}
/**
* Called on update.
*
* @param InstallerAdapter $parent The parent installer object.
*
* @return boolean True on success.
*
* @since 1.0.0
*/
public function update(InstallerAdapter $parent): bool
{
return true;
}
/**
* Called on uninstallation.
*
* @param InstallerAdapter $parent The parent installer object.
*
* @return boolean True on success.
*
* @since 1.0.0
*/
public function uninstall(InstallerAdapter $parent): bool
{
return true;
}
/**
* Called after any type of action.
*
* @param string $type Installation type.
* @param InstallerAdapter $parent The parent installer object.
*
* @return boolean True on success.
*
* @since 1.0.0
*/
public function postflight(string $type, InstallerAdapter $parent): bool
{
return true;
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokojoomstorelocator">
<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.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.delete" title="JACTION_DELETE" />
</section>
<section name="category">
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.delete" title="JACTION_DELETE" />
</section>
</access>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<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_FIELD_PARENT_CATEGORY"
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published = 1 ORDER BY title"
key_field="id"
value_field="title"
header="COM_MOKOJOOMSTORELOCATOR_NO_PARENT"
/>
<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_FIELD_COLOR"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC"
default="#3b82f6"
/>
<field
name="marker_icon"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC"
directory="storelocator/markers"
/>
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE"
/>
</fieldset>
</form>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by category name" />
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
</fields>
<fields name="list">
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.ordering ASC" onchange="this.form.submit()">
<option value="a.ordering ASC">JGRID_HEADING_ORDERING</option>
<option value="a.title ASC">JGLOBAL_TITLE</option>
</field>
</fields>
</form>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by title, address, city, postcode" />
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
<field name="category_id" type="sql" label="Category" onchange="this.form.submit()"
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published=1 ORDER BY title"
key_field="id" value_field="title">
<option value="">- All Categories -</option>
</field>
<field name="city" type="sql" label="City" onchange="this.form.submit()"
query="SELECT DISTINCT city FROM #__mokojoomstorelocator_locations WHERE city != '' ORDER BY city"
key_field="city" value_field="city">
<option value="">- All Cities -</option>
</field>
<field name="state" type="sql" label="State" onchange="this.form.submit()"
query="SELECT DISTINCT state FROM #__mokojoomstorelocator_locations WHERE state != '' ORDER BY state"
key_field="state" value_field="state">
<option value="">- All States -</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.ordering ASC">JGRID_HEADING_ORDERING</option>
<option value="a.title ASC">JGLOBAL_TITLE</option>
<option value="a.city ASC">City</option>
<option value="a.state ASC">State</option>
<option value="a.id DESC">Newest</option>
</field>
</fields>
</form>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Location edit form -->
<form>
<fieldset name="details" addfieldprefix="Moko\Component\MokoSuiteStoreLocator\Administrator\Field">
<fieldset name="details" addfieldprefix="Moko\Component\MokoJoomStoreLocator\Administrator\Field">
<field
name="id"
type="hidden"
@@ -41,6 +41,15 @@
<option value="0">JUNPUBLISHED</option>
<option value="-2">JTRASHED</option>
</field>
<field
name="category_ids"
type="LocationCategories"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC"
multiple="true"
class="form-select"
/>
</fieldset>
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
@@ -130,11 +139,30 @@
/>
</fieldset>
<fieldset name="image" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE">
<fieldset name="media" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA">
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC"
/>
<field
name="images"
type="textarea"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC"
rows="4"
hint="images/stores/photo1.jpg&#10;images/stores/photo2.jpg"
/>
<field
name="video_url"
type="url"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC"
size="60"
hint="https://www.youtube.com/watch?v=..."
/>
</fieldset>
</form>
@@ -0,0 +1,87 @@
; MokoJoomStoreLocator - Admin language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
COM_MOKOJOOMSTORELOCATOR_CITY="City"
COM_MOKOJOOMSTORELOCATOR_STATE="State"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding"
COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider"
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key"
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com"
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save"
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location."
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import"
COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers."
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted."
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates"
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider."
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates."
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:"
COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template"
COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped."
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
COM_MOKOJOOMSTORELOCATOR_PARENT="Parent"
COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —"
COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category"
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker"
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color"
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set."
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon"
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker."
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image"
COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields"
COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields"
COM_MOKOJOOMSTORELOCATOR_EXPORT="Export"
COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
@@ -1,4 +1,4 @@
; MokoSuiteStoreLocator - System language strings
; MokoJoomStoreLocator - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
@@ -0,0 +1,87 @@
; MokoJoomStoreLocator - Admin language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
COM_MOKOJOOMSTORELOCATOR_CITY="City"
COM_MOKOJOOMSTORELOCATOR_STATE="State"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding"
COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider"
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key"
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com"
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save"
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location."
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import"
COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers."
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted."
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates"
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider."
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates."
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:"
COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template"
COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped."
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
COM_MOKOJOOMSTORELOCATOR_PARENT="Parent"
COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —"
COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category"
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker"
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color"
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set."
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon"
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker."
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image"
COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields"
COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields"
COM_MOKOJOOMSTORELOCATOR_EXPORT="Export"
COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?"
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
@@ -0,0 +1,7 @@
; MokoJoomStoreLocator - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
@@ -1,23 +1,21 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Moko\Component\MokoSuiteStoreLocator\Administrator\Extension\MokoSuiteStoreLocatorComponent;
use Moko\Component\MokoJoomStoreLocator\Administrator\Extension\MokoJoomStoreLocatorComponent;
/**
* The store locator service provider.
@@ -37,18 +35,16 @@ return new class implements ServiceProviderInterface
*/
public function register(Container $container): void
{
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
$container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoJoomStoreLocator'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoJoomStoreLocator'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new MokoSuiteStoreLocatorComponent(
$component = new MokoJoomStoreLocatorComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
return $component;
}
@@ -2,10 +2,11 @@
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- SPDX-License-Identifier: GPL-3.0-or-later
--
-- MokoSuiteStoreLocator - Store locations table
-- MokoJoomStoreLocator - Database schema
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
-- Store locations
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
@@ -22,6 +23,8 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
`website` varchar(255) NOT NULL DEFAULT '',
`hours` text NOT NULL,
`image` varchar(255) NOT NULL DEFAULT '',
`images` text NOT NULL,
`video_url` varchar(500) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 0,
`ordering` int(11) NOT NULL DEFAULT 0,
`catid` int(11) NOT NULL DEFAULT 0,
@@ -38,3 +41,32 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
KEY `idx_alias` (`alias`(191)),
KEY `idx_coordinates` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Location categories with hierarchy, color, and custom marker support
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_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 '#3b82f6',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`image` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 0,
`ordering` int(11) NOT NULL DEFAULT 0,
`params` text NOT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent` (`parent_id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Many-to-many: locations <-> categories
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category` (`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_locations`;
DROP TABLE IF EXISTS `#__mokojoomstorelocator_location_categories`;
DROP TABLE IF EXISTS `#__mokojoomstorelocator_categories`;
DROP TABLE IF EXISTS `#__mokojoomstorelocator_locations`;
@@ -0,0 +1 @@
-- v1.0.0 initial schema marker
@@ -0,0 +1,30 @@
-- v1.0.1: Add images and video_url columns, categories table, junction table
ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `images` text NOT NULL AFTER `image`;
ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `video_url` varchar(500) NOT NULL DEFAULT '' AFTER `images`;
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_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 '#3b82f6',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`image` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 0,
`ordering` int(11) NOT NULL DEFAULT 0,
`params` text NOT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent` (`parent_id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,14 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
class CategoriesController extends AdminController
{
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,11 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
class CategoryController extends FormController
{
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_CATEGORY';
}
@@ -1,12 +1,12 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
@@ -0,0 +1,103 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
/**
* Controller for exporting locations to CSV.
*
* @since 1.0.0
*/
class ExportController extends BaseController
{
/**
* Export locations as a CSV download.
*
* @return void
*
* @since 1.0.0
*/
public function execute(): void
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
->order($db->quoteName('a.title') . ' ASC');
// Apply filters from request
$app = Factory::getApplication();
$published = $app->getInput()->getInt('filter_published', null);
if ($published !== null)
{
$query->where($db->quoteName('a.published') . ' = :published')
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
}
$city = $app->getInput()->getString('filter_city', '');
if ($city)
{
$query->where($db->quoteName('a.city') . ' = :city')
->bind(':city', $city);
}
$db->setQuery($query);
$locations = $db->loadObjectList();
// CSV columns
$columns = [
'id', 'title', 'alias', 'description', 'address', 'city', 'state',
'postcode', 'country', 'latitude', 'longitude', 'phone', 'email',
'website', 'hours', 'published',
];
// Output CSV
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
$app->setHeader('Content-Disposition', 'attachment; filename="store-locations-' . date('Y-m-d') . '.csv"');
$app->setHeader('Cache-Control', 'no-cache, must-revalidate');
$app->sendHeaders();
$output = fopen('php://output', 'w');
// BOM for Excel UTF-8 compatibility
fwrite($output, "\xEF\xBB\xBF");
// Header row
fputcsv($output, $columns);
// Data rows
foreach ($locations as $location)
{
$row = [];
foreach ($columns as $col)
{
$row[] = $location->$col ?? '';
}
fputcsv($output, $row);
}
fclose($output);
$app->close();
}
}
@@ -0,0 +1,248 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder;
/**
* Controller for importing locations from CSV files.
*
* @since 1.0.0
*/
class ImportController extends BaseController
{
/**
* Process the uploaded CSV file and import locations.
*
* @return void
*
* @since 1.0.0
*/
public function execute(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->getInput();
$file = $input->files->get('import_file', [], 'array');
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK)
{
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error');
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false));
return;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv')
{
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT'), 'error');
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false));
return;
}
$geocodeOnImport = (bool) $input->getInt('geocode', 0);
$updateExisting = (bool) $input->getInt('update_existing', 0);
// Column mapping from the enhanced import UI (JSON string: {"0":"title","1":"address",...})
$columnMapJson = $input->getString('column_map', '');
$columnMap = $columnMapJson ? json_decode($columnMapJson, true) : null;
$result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting, $columnMap);
$app->enqueueMessage(
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT', $result['imported'], $result['updated'], $result['skipped']),
'success'
);
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false));
}
/**
* Parse and import a CSV file.
*
* Expected columns: title, address, city, state, postcode, country, latitude, longitude,
* phone, email, website, hours, description
*
* @param string $filePath Path to the CSV file.
* @param bool $geocodeOnImport Whether to geocode missing coordinates.
* @param bool $updateExisting Whether to update existing records by title match.
* @param array|null $columnMap Column mapping from UI: {"csv_index":"field_name",...}
*
* @return array ['imported' => int, 'updated' => int, 'skipped' => int]
*
* @since 1.0.0
*/
private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting, ?array $columnMap = null): array
{
$handle = fopen($filePath, 'r');
if (!$handle)
{
return ['imported' => 0, 'updated' => 0, 'skipped' => 0];
}
// Read header row
$headers = fgetcsv($handle);
if (!$headers)
{
fclose($handle);
return ['imported' => 0, 'updated' => 0, 'skipped' => 0];
}
// If column map provided from UI, use it; otherwise fall back to header-based mapping
if ($columnMap)
{
// columnMap is {"csv_index": "field_name", ...}
$useColumnMap = true;
}
else
{
$headers = array_map('strtolower', array_map('trim', $headers));
$useColumnMap = false;
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$geocoder = $geocodeOnImport ? new Geocoder() : null;
$imported = 0;
$updated = 0;
$skipped = 0;
while (($row = fgetcsv($handle)) !== false)
{
if (count($row) < 2)
{
$skipped++;
continue;
}
if ($useColumnMap)
{
$data = [];
foreach ($columnMap as $csvIndex => $fieldName)
{
$data[$fieldName] = $row[(int) $csvIndex] ?? '';
}
}
else
{
$data = @array_combine($headers, $row) ?: [];
}
if (empty($data['title']))
{
$skipped++;
continue;
}
// Check for existing record
$existingId = null;
if ($updateExisting)
{
$query = $db->getQuery(true)
->select('id')
->from($db->quoteName('#__mokojoomstorelocator_locations'))
->where($db->quoteName('title') . ' = :title')
->bind(':title', $data['title']);
$db->setQuery($query);
$existingId = $db->loadResult();
}
// Geocode if needed
if ($geocoder && empty($data['latitude']) && empty($data['longitude']))
{
$coords = $geocoder->geocode(
$data['address'] ?? '',
$data['city'] ?? '',
$data['state'] ?? '',
$data['postcode'] ?? '',
$data['country'] ?? ''
);
if ($coords)
{
$data['latitude'] = $coords['lat'];
$data['longitude'] = $coords['lng'];
}
}
// Build table record
$table = $this->getModel('Location')->getTable();
if ($existingId)
{
$table->load($existingId);
}
$locationData = [
'title' => $data['title'] ?? '',
'address' => $data['address'] ?? '',
'city' => $data['city'] ?? '',
'state' => $data['state'] ?? '',
'postcode' => $data['postcode'] ?? '',
'country' => $data['country'] ?? '',
'latitude' => !empty($data['latitude']) ? (float) $data['latitude'] : null,
'longitude' => !empty($data['longitude']) ? (float) $data['longitude'] : null,
'phone' => $data['phone'] ?? '',
'email' => $data['email'] ?? '',
'website' => $data['website'] ?? '',
'hours' => $data['hours'] ?? '',
'description' => $data['description'] ?? '',
'published' => 1,
];
if (!$table->bind($locationData))
{
$skipped++;
continue;
}
if (!$table->check())
{
$skipped++;
continue;
}
if (!$table->store())
{
Log::add('Import failed for: ' . $data['title'] . ' — ' . $table->getError(), Log::WARNING, 'com_mokojoomstorelocator');
$skipped++;
continue;
}
if ($existingId)
{
$updated++;
}
else
{
$imported++;
}
}
fclose($handle);
return ['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped];
}
}
@@ -0,0 +1,31 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Controller for a single location record.
*
* Handles save, cancel, edit actions for the location edit form.
*
* @since 1.0.0
*/
class LocationController extends FormController
{
/**
* The prefix for the model class name.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
}
@@ -1,38 +1,32 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
/**
* Locations list controller handles bulk publish/unpublish/delete.
* Controller for the locations list.
*
* Handles publish, unpublish, delete, and ordering actions on the list view.
*
* @since 1.0.0
*/
class LocationsController extends AdminController
{
/**
* The prefix to use with controller messages.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATIONS';
/**
* Proxy for getModel.
*
* @param string $name The model name.
* @param string $prefix The model prefix.
* @param array $config Configuration array for model.
* @param array $config Configuration array.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
@@ -0,0 +1,269 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/**
* Controller for sample data operations.
*
* @since 1.0.0
*/
class SampledataController extends BaseController
{
/**
* Download a CSV template file with column headers and a sample row.
*
* @return void
*
* @since 1.0.0
*/
public function download(): void
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
$app->setHeader('Content-Disposition', 'attachment; filename="store-locations-template.csv"');
$app->sendHeaders();
$output = fopen('php://output', 'w');
fwrite($output, "\xEF\xBB\xBF");
$headers = ['title', 'address', 'city', 'state', 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', 'website', 'hours', 'description'];
fputcsv($output, $headers);
fputcsv($output, [
'Moko HQ',
'123 Main Street',
'Nashville',
'TN',
'37201',
'US',
'36.1627',
'-86.7816',
'(615) 555-0100',
'hello@example.com',
'https://example.com',
'Mon-Fri 9am-5pm',
'Our main office location.',
]);
fclose($output);
$app->close();
}
/**
* Inject sample location data into the database for testing.
*
* @return void
*
* @since 1.0.0
*/
public function inject(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$uid = Factory::getApplication()->getIdentity()->id ?? 0;
$samples = [
[
'title' => 'Downtown Nashville Store',
'alias' => 'downtown-nashville-store',
'description' => 'Our flagship location in the heart of downtown Nashville.',
'address' => '200 Broadway',
'city' => 'Nashville',
'state' => 'TN',
'postcode' => '37201',
'country' => 'US',
'latitude' => 36.1622,
'longitude' => -86.7744,
'phone' => '(615) 555-0101',
'email' => 'downtown@example.com',
'website' => 'https://example.com/downtown',
'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-6pm\nSun: 12pm-5pm",
],
[
'title' => 'East Nashville Location',
'alias' => 'east-nashville-location',
'description' => 'Serving the East Nashville community with friendly service.',
'address' => '1000 Main St',
'city' => 'Nashville',
'state' => 'TN',
'postcode' => '37206',
'country' => 'US',
'latitude' => 36.1781,
'longitude' => -86.7534,
'phone' => '(615) 555-0102',
'email' => 'east@example.com',
'website' => 'https://example.com/east',
'hours' => "Mon-Sat: 10am-8pm\nSun: Closed",
],
[
'title' => 'Franklin Square',
'alias' => 'franklin-square',
'description' => 'Conveniently located in the Franklin town square.',
'address' => '400 Main St',
'city' => 'Franklin',
'state' => 'TN',
'postcode' => '37064',
'country' => 'US',
'latitude' => 35.9251,
'longitude' => -86.8689,
'phone' => '(615) 555-0103',
'email' => 'franklin@example.com',
'website' => 'https://example.com/franklin',
'hours' => "Mon-Fri: 8am-6pm\nSat-Sun: 10am-4pm",
],
[
'title' => 'Murfreesboro Plaza',
'alias' => 'murfreesboro-plaza',
'description' => 'Our newest location serving Rutherford County.',
'address' => '1720 Old Fort Pkwy',
'city' => 'Murfreesboro',
'state' => 'TN',
'postcode' => '37129',
'country' => 'US',
'latitude' => 35.8353,
'longitude' => -86.4160,
'phone' => '(615) 555-0104',
'email' => 'murfreesboro@example.com',
'website' => 'https://example.com/murfreesboro',
'hours' => "Mon-Sat: 9am-9pm\nSun: 11am-6pm",
],
[
'title' => 'Clarksville Center',
'alias' => 'clarksville-center',
'description' => 'Serving the Clarksville-Montgomery County area.',
'address' => '2801 Wilma Rudolph Blvd',
'city' => 'Clarksville',
'state' => 'TN',
'postcode' => '37040',
'country' => 'US',
'latitude' => 36.5843,
'longitude' => -87.3199,
'phone' => '(931) 555-0105',
'email' => 'clarksville@example.com',
'website' => 'https://example.com/clarksville',
'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-5pm\nSun: Closed",
],
[
'title' => 'Chattanooga Riverfront',
'alias' => 'chattanooga-riverfront',
'description' => 'Located near the Tennessee Aquarium on the riverfront.',
'address' => '1 Broad St',
'city' => 'Chattanooga',
'state' => 'TN',
'postcode' => '37402',
'country' => 'US',
'latitude' => 35.0557,
'longitude' => -85.3097,
'phone' => '(423) 555-0106',
'email' => 'chattanooga@example.com',
'website' => 'https://example.com/chattanooga',
'hours' => "Daily: 10am-8pm",
],
[
'title' => 'Knoxville Market Square',
'alias' => 'knoxville-market-square',
'description' => 'In the heart of downtown Knoxville at Market Square.',
'address' => '36 Market Square',
'city' => 'Knoxville',
'state' => 'TN',
'postcode' => '37902',
'country' => 'US',
'latitude' => 35.9643,
'longitude' => -83.9198,
'phone' => '(865) 555-0107',
'email' => 'knoxville@example.com',
'website' => 'https://example.com/knoxville',
'hours' => "Mon-Sat: 9am-8pm\nSun: 12pm-6pm",
],
[
'title' => 'Memphis Beale Street',
'alias' => 'memphis-beale-street',
'description' => 'Right on iconic Beale Street in downtown Memphis.',
'address' => '152 Beale St',
'city' => 'Memphis',
'state' => 'TN',
'postcode' => '38103',
'country' => 'US',
'latitude' => 35.1393,
'longitude' => -90.0530,
'phone' => '(901) 555-0108',
'email' => 'memphis@example.com',
'website' => 'https://example.com/memphis',
'hours' => "Mon-Thu: 10am-10pm\nFri-Sat: 10am-12am\nSun: 11am-8pm",
],
];
$inserted = 0;
foreach ($samples as $sample)
{
$sample['published'] = 1;
$sample['ordering'] = $inserted + 1;
$sample['params'] = '{}';
$sample['image'] = '';
$sample['catid'] = 0;
$sample['created'] = $now;
$sample['created_by'] = $uid;
$sample['modified'] = $now;
$sample['modified_by'] = $uid;
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokojoomstorelocator_locations'))
->where($db->quoteName('alias') . ' = :alias')
->bind(':alias', $sample['alias']);
$db->setQuery($query);
if ($db->loadResult() > 0)
{
continue;
}
$query = $db->getQuery(true)
->insert($db->quoteName('#__mokojoomstorelocator_locations'))
->columns($db->quoteName(array_keys($sample)))
->values(implode(',', array_map(function ($v) use ($db) {
return $db->quote($v);
}, array_values($sample))));
$db->setQuery($query);
try
{
$db->execute();
$inserted++;
}
catch (\Exception $e)
{
Log::add('Sample data insert failed: ' . $e->getMessage(), Log::WARNING, 'com_mokojoomstorelocator');
}
}
$app->enqueueMessage(
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED', $inserted),
'success'
);
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false));
}
}
@@ -0,0 +1,75 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Fields\FieldsServiceInterface;
/**
* Component class for com_mokojoomstorelocator.
*
* Implements RouterServiceInterface for SEF URLs and FieldsServiceInterface
* for Joomla custom fields (com_fields) integration.
*
* @since 1.0.0
*/
class MokoJoomStoreLocatorComponent extends MVCComponent implements RouterServiceInterface, FieldsServiceInterface
{
use RouterServiceTrait;
/**
* Returns the contexts available for custom fields.
*
* @return string[] Array of context names.
*
* @since 1.0.0
*/
public function getContexts(): array
{
return [
'com_mokojoomstorelocator.location' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION',
'com_mokojoomstorelocator.category' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY',
];
}
/**
* Validates a field context.
*
* @param string $context The context to validate.
*
* @return array|null Validated context parts or null.
*
* @since 1.0.0
*/
public function validateSection($section, $item = null): ?string
{
if ($section === 'location' || $section === 'category')
{
return $section;
}
return null;
}
/**
* Returns valid sections for custom fields.
*
* @return array Section names.
*
* @since 1.0.0
*/
public function getValidSections(): array
{
return ['location', 'category'];
}
}
@@ -0,0 +1,50 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Field\ListField;
use Joomla\Database\DatabaseInterface;
/**
* Multi-select field for assigning categories to a location.
*
* @since 1.0.0
*/
class LocationCategoriesField extends ListField
{
protected $type = 'LocationCategories';
protected function getOptions(): array
{
$options = parent::getOptions();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select([$db->quoteName('id', 'value'), $db->quoteName('title', 'text')])
->from($db->quoteName('#__mokojoomstorelocator_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('title') . ' ASC');
$db->setQuery($query);
$categories = $db->loadObjectList();
foreach ($categories as $cat)
{
$options[] = (object) [
'value' => $cat->value,
'text' => $cat->text,
];
}
return $options;
}
}
@@ -0,0 +1,227 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Log\Log;
/**
* Geocoding helper — converts addresses to latitude/longitude coordinates.
*
* Supports Nominatim (OpenStreetMap, free, default) and Google Geocoding API.
*
* @since 1.0.0
*/
class Geocoder
{
/**
* @var string Geocoding provider: 'nominatim' or 'google'.
* @since 1.0.0
*/
private string $provider;
/**
* @var string Google Geocoding API key (only for google provider).
* @since 1.0.0
*/
private string $apiKey;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$params = ComponentHelper::getParams('com_mokojoomstorelocator');
$this->provider = $params->get('geocoder_provider', 'nominatim');
$this->apiKey = $params->get('google_api_key', '');
}
/**
* Geocode an address string to coordinates.
*
* @param string $address Street address.
* @param string $city City.
* @param string $state State/province.
* @param string $postcode Postal code.
* @param string $country Country.
*
* @return array|null ['lat' => float, 'lng' => float] or null on failure.
*
* @since 1.0.0
*/
public function geocode(string $address = '', string $city = '', string $state = '', string $postcode = '', string $country = ''): ?array
{
$parts = array_filter([$address, $city, $state, $postcode, $country]);
$query = implode(', ', $parts);
if (empty($query))
{
return null;
}
if ($this->provider === 'google' && $this->apiKey)
{
return $this->geocodeGoogle($query);
}
return $this->geocodeNominatim($query);
}
/**
* Batch geocode multiple locations.
*
* @param array $locations Array of arrays with address fields.
*
* @return array Array of results indexed by input key: ['lat' => float, 'lng' => float] or null.
*
* @since 1.0.0
*/
public function batchGeocode(array $locations): array
{
$results = [];
foreach ($locations as $key => $loc)
{
$results[$key] = $this->geocode(
$loc['address'] ?? '',
$loc['city'] ?? '',
$loc['state'] ?? '',
$loc['postcode'] ?? '',
$loc['country'] ?? ''
);
// Nominatim rate limit: max 1 request per second
if ($this->provider === 'nominatim')
{
usleep(1100000);
}
}
return $results;
}
/**
* Geocode using OpenStreetMap Nominatim (free, no API key).
*
* @param string $query The full address string.
*
* @return array|null Coordinates or null.
*
* @since 1.0.0
*/
private function geocodeNominatim(string $query): ?array
{
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'q' => $query,
'format' => 'json',
'limit' => 1,
]);
$result = $this->httpGet($url, [
'User-Agent' => 'MokoJoomStoreLocator/1.0 (Joomla component)',
'Accept' => 'application/json',
]);
if ($result === null || empty($result))
{
return null;
}
$first = $result[0] ?? null;
if (!$first || !isset($first['lat'], $first['lon']))
{
return null;
}
return [
'lat' => (float) $first['lat'],
'lng' => (float) $first['lon'],
];
}
/**
* Geocode using Google Geocoding API.
*
* @param string $query The full address string.
*
* @return array|null Coordinates or null.
*
* @since 1.0.0
*/
private function geocodeGoogle(string $query): ?array
{
$url = 'https://maps.googleapis.com/maps/api/geocode/json?' . http_build_query([
'address' => $query,
'key' => $this->apiKey,
]);
$result = $this->httpGet($url);
if ($result === null || ($result['status'] ?? '') !== 'OK')
{
$status = $result['status'] ?? 'unknown';
Log::add("Geocoder: Google returned status $status for: $query", Log::WARNING, 'com_mokojoomstorelocator');
return null;
}
$location = $result['results'][0]['geometry']['location'] ?? null;
if (!$location)
{
return null;
}
return [
'lat' => (float) $location['lat'],
'lng' => (float) $location['lng'],
];
}
/**
* Perform an HTTP GET request and return decoded JSON.
*
* @param string $url The URL to fetch.
* @param array $headers Additional headers.
*
* @return array|null Decoded JSON or null on error.
*
* @since 1.0.0
*/
private function httpGet(string $url, array $headers = []): ?array
{
try
{
$http = HttpFactory::getHttp();
$response = $http->get($url, $headers, 15);
if ($response->code !== 200)
{
Log::add("Geocoder: HTTP {$response->code} from $url", Log::WARNING, 'com_mokojoomstorelocator');
return null;
}
return json_decode($response->body, true);
}
catch (\Exception $e)
{
Log::add('Geocoder: ' . $e->getMessage(), Log::ERROR, 'com_mokojoomstorelocator');
return null;
}
}
}
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
/**
* Helper for parsing video URLs into embeddable iframes.
*
* @since 1.0.0
*/
class VideoHelper
{
/**
* Convert a YouTube or Vimeo URL to an embed URL.
*
* @param string $url The video URL.
*
* @return string|null The embed URL or null if not recognized.
*
* @since 1.0.0
*/
public static function getEmbedUrl(string $url): ?string
{
$url = trim($url);
if (empty($url))
{
return null;
}
// YouTube
if (preg_match('/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/', $url, $m))
{
return 'https://www.youtube-nocookie.com/embed/' . $m[1];
}
// Vimeo
if (preg_match('/vimeo\.com\/(\d+)/', $url, $m))
{
return 'https://player.vimeo.com/video/' . $m[1];
}
return null;
}
}
@@ -0,0 +1,80 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Categories list model.
*
* @since 1.0.0
*/
class CategoriesModel extends ListModel
{
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_id', 'a.parent_id',
];
}
parent::__construct($config);
}
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select([
'a.*',
'p.title AS parent_title',
'(SELECT COUNT(*) FROM ' . $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')
. ' WHERE lc.category_id = a.id) AS location_count',
])
->from($db->quoteName('#__mokojoomstorelocator_categories', 'a'))
->join('LEFT', $db->quoteName('#__mokojoomstorelocator_categories', 'p') . ' ON p.id = a.parent_id');
$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 = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')
->bind(':search', $search);
}
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
return $query;
}
}
@@ -0,0 +1,60 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Table\Table;
/**
* Single category edit model.
*
* @since 1.0.0
*/
class CategoryModel extends AdminModel
{
public $typeAlias = 'com_mokojoomstorelocator.category';
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokojoomstorelocator.category',
'category',
['control' => 'jform', 'load_data' => $loadData]
);
return empty($form) ? false : $form;
}
protected function loadFormData()
{
return $this->getItem();
}
public function getTable($name = 'Category', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
/**
* Save category and update the location-category junction table.
*
* @param array $data The form data.
*
* @return boolean
*
* @since 1.0.0
*/
public function save($data)
{
return parent::save($data);
}
}
@@ -0,0 +1,152 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
/**
* Single location edit model with multi-category support.
*
* @since 1.0.0
*/
class LocationModel extends AdminModel
{
public $typeAlias = 'com_mokojoomstorelocator.location';
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokojoomstorelocator.location',
'location',
['control' => 'jform', 'load_data' => $loadData]
);
return empty($form) ? false : $form;
}
protected function loadFormData()
{
$data = $this->getItem();
// Load assigned category IDs for the multi-select field
if ($data && $data->id)
{
$data->category_ids = $this->getCategoryIds((int) $data->id);
}
return $data;
}
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
/**
* Override save to handle the many-to-many category relationship.
*
* @param array $data The form data.
*
* @return boolean
*
* @since 1.0.0
*/
public function save($data)
{
$categoryIds = $data['category_ids'] ?? [];
unset($data['category_ids']);
if (!parent::save($data))
{
return false;
}
$locationId = (int) $this->getState($this->getName() . '.id');
$this->saveCategoryAssignments($locationId, (array) $categoryIds);
return true;
}
/**
* Get category IDs assigned to a location.
*
* @param int $locationId The location ID.
*
* @return array Array of category IDs.
*
* @since 1.0.0
*/
public function getCategoryIds(int $locationId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select($db->quoteName('category_id'))
->from($db->quoteName('#__mokojoomstorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :id')
->bind(':id', $locationId, ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadColumn() ?: [];
}
/**
* Save category assignments for a location (replace all).
*
* @param int $locationId The location ID.
* @param array $categoryIds Array of category IDs to assign.
*
* @return void
*
* @since 1.0.0
*/
private function saveCategoryAssignments(int $locationId, array $categoryIds): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Delete existing assignments
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokojoomstorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :id')
->bind(':id', $locationId, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
// Insert new assignments
if (empty($categoryIds))
{
return;
}
$query = $db->getQuery(true)
->insert($db->quoteName('#__mokojoomstorelocator_location_categories'))
->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]);
foreach ($categoryIds as $catId)
{
$catId = (int) $catId;
if ($catId > 0)
{
$query->values($locationId . ',' . $catId);
}
}
$db->setQuery($query);
$db->execute();
}
}
@@ -1,12 +1,12 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
@@ -44,17 +44,7 @@ class LocationsModel extends ListModel
parent::__construct($config);
}
/**
* Populate the model state.
*
* @param string $ordering Default ordering column.
* @param string $direction Default ordering direction.
*
* @return void
*
* @since 1.0.0
*/
protected function populateState($ordering = 'a.title', $direction = 'ASC')
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
{
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
$this->setState('filter.search', $search);
@@ -62,6 +52,9 @@ class LocationsModel extends ListModel
$published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string');
$this->setState('filter.published', $published);
$categoryId = $this->getUserStateFromRequest($this->context . '.filter.category_id', 'filter_category_id', 0, 'int');
$this->setState('filter.category_id', $categoryId);
parent::populateState($ordering, $direction);
}
@@ -78,9 +71,8 @@ class LocationsModel extends ListModel
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'));
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'));
// Filter by published state
$published = $this->getState('filter.published');
if (is_numeric($published))
@@ -93,27 +85,33 @@ class LocationsModel extends ListModel
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
}
// Search filter
$catId = (int) $this->getState('filter.category_id');
if ($catId > 0)
{
$query->where($db->quoteName('a.catid') . ' = :catid')
->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER);
}
$search = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where(
'(' . $db->quoteName('a.title') . ' LIKE :search'
'(' . $db->quoteName('a.title') . ' LIKE :search1'
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
. ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search3'
. ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)'
)
->bind(':search', $search)
->bind(':search1', $search)
->bind(':search2', $search)
->bind(':search3', $search)
->bind(':search4', $search);
}
// Ordering
$orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC');
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
return $query;
@@ -0,0 +1,58 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Category table class.
*
* @since 1.0.0
*/
class CategoryTable extends Table
{
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokojoomstorelocator_categories', 'id', $db);
}
public function check(): bool
{
if (empty($this->title))
{
$this->setError('A category title is required.');
return false;
}
if (empty($this->alias))
{
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
if (empty($this->alias))
{
$this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s');
}
// Validate color is a hex value
if (!empty($this->color) && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color))
{
$this->color = '#3b82f6';
}
return parent::check();
}
}
@@ -0,0 +1,112 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Location table class.
*
* @since 1.0.0
*/
class LocationTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver object.
*
* @since 1.0.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokojoomstorelocator_locations', 'id', $db);
$this->setColumnAlias('published', 'published');
}
/**
* Overloaded check method to ensure data integrity.
*
* @return boolean True if the data is valid.
*
* @since 1.0.0
*/
public function check(): bool
{
if (empty($this->title))
{
$this->setError('A location title is required.');
return false;
}
if (empty($this->alias))
{
$this->alias = $this->title;
}
$this->alias = \Joomla\CMS\Filter\OutputFilter::stringURLSafe($this->alias);
if (empty($this->alias))
{
$this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s');
}
if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90))
{
$this->setError('Latitude must be between -90 and 90.');
return false;
}
if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180))
{
$this->setError('Longitude must be between -180 and 180.');
return false;
}
$now = \Joomla\CMS\Factory::getDate()->toSql();
if (empty($this->created) || $this->created === '0000-00-00 00:00:00')
{
$this->created = $now;
$this->created_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0;
}
$this->modified = $now;
$this->modified_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0;
// Auto-geocode if address present but coordinates missing
if ((empty($this->latitude) || empty($this->longitude))
&& (!empty($this->address) || !empty($this->city)))
{
$geocoder = new \Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder();
$coords = $geocoder->geocode(
$this->address ?? '',
$this->city ?? '',
$this->state ?? '',
$this->postcode ?? '',
$this->country ?? ''
);
if ($coords)
{
$this->latitude = $coords['lat'];
$this->longitude = $coords['lng'];
}
}
return parent::check();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
ToolbarHelper::title('Store Locator: Categories');
ToolbarHelper::addNew('category.add');
ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE');
parent::display($tpl);
}
}
@@ -0,0 +1,17 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
class JsonapiView extends BaseApiView
{
protected $fieldsToRenderItem = [
'id', 'title', 'alias', 'description', 'color', 'marker_icon', 'parent_id', 'published',
];
protected $fieldsToRenderList = [
'id', 'title', 'alias', 'color', 'parent_id', 'published',
];
}
@@ -0,0 +1,31 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $form;
protected $item;
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
Factory::getApplication()->getInput()->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title($isNew ? 'Store Locator: New Category' : 'Store Locator: Edit Category');
ToolbarHelper::apply('category.apply');
ToolbarHelper::save('category.save');
ToolbarHelper::save2new('category.save2new');
ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
parent::display($tpl);
}
}
@@ -1,38 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Import;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Import;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Import view for CSV upload.
* Import view.
*
* @since 1.1.0
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* Display the import form.
*
* @param string $tpl The template name.
* @param string $tpl Template name.
*
* @return void
*
* @since 1.1.0
* @since 1.0.0
*/
public function display($tpl = null): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT'), 'upload');
ToolbarHelper::title('Store Locator: Import Locations');
parent::display($tpl);
}
@@ -1,17 +1,16 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Location;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
@@ -23,16 +22,12 @@ use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/**
* The form object.
*
* @var \Joomla\CMS\Form\Form
* @since 1.0.0
*/
protected $form;
/**
* The item being edited.
*
* @var object
* @since 1.0.0
*/
@@ -41,7 +36,7 @@ class HtmlView extends BaseHtmlView
/**
* Display the view.
*
* @param string $tpl The template name.
* @param string $tpl Template name.
*
* @return void
*
@@ -66,13 +61,12 @@ class HtmlView extends BaseHtmlView
*/
protected function addToolbar(): void
{
Factory::getApplication()->input->set('hidemainmenu', true);
Factory::getApplication()->getInput()->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title(
Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATION_' . ($isNew ? 'NEW' : 'EDIT')),
'location'
$isNew ? 'Store Locator: New Location' : 'Store Locator: Edit Location'
);
ToolbarHelper::apply('location.apply');
@@ -1,17 +1,18 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Locations;
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
@@ -22,35 +23,29 @@ use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/**
* The list of locations.
*
* @var array
* @since 1.0.0
*/
protected $items;
/**
* The pagination object.
*
* @var \Joomla\CMS\Pagination\Pagination
* @since 1.0.0
*/
protected $pagination;
/**
* The model state.
*
* @var \Joomla\Registry\Registry
* @since 1.0.0
*/
protected $state;
/**
* @var \Joomla\CMS\Form\Form
* @since 1.0.0
*/
public $filterForm;
/**
* @var array
* @since 1.0.0
*/
public $activeFilters;
/**
* Display the view.
*
@@ -62,11 +57,9 @@ class HtmlView extends BaseHtmlView
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->addToolbar();
@@ -82,11 +75,20 @@ class HtmlView extends BaseHtmlView
*/
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'), 'location');
ToolbarHelper::title('Store Locator: Locations');
ToolbarHelper::addNew('location.add');
ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
ToolbarHelper::custom('import.display', 'upload', '', 'COM_MOKOJOOMSTORELOCATOR_IMPORT', false);
$toolbar = Toolbar::getInstance('toolbar');
$exportUrl = 'index.php?option=com_mokojoomstorelocator&task=export.execute&' . Session::getFormToken() . '=1';
$toolbar->standardButton('download', 'COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV', '')->icon('icon-download')->url($exportUrl);
$sampleUrl = 'index.php?option=com_mokojoomstorelocator&task=sampledata.inject&' . Session::getFormToken() . '=1';
$toolbar->standardButton('lightning', 'COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT', '')->icon('icon-lightning')->url($sampleUrl);
ToolbarHelper::preferences('com_mokojoomstorelocator');
}
}
@@ -0,0 +1,25 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
/**
* JSON API view for locations list.
*
* @since 1.0.0
*/
class JsonapiView extends BaseApiView
{
protected $fieldsToRenderItem = [
'id', 'title', 'alias', 'description', 'address', 'city', 'state',
'postcode', 'country', 'latitude', 'longitude', 'phone', 'email',
'website', 'hours', 'image', 'published',
];
protected $fieldsToRenderList = [
'id', 'title', 'alias', 'address', 'city', 'state', 'postcode',
'latitude', 'longitude', 'phone', 'published',
];
}
@@ -0,0 +1,70 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=categories'); ?>"
method="post" name="adminForm" id="adminForm">
<div id="j-main-container" class="j-main-container">
<?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">
<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-10"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR'); ?></th>
<th scope="col" class="w-10 d-none d-md-table-cell"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PARENT'); ?></th>
<th scope="col" class="w-10 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="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=category.edit&id=' . $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<?php if ($item->marker_icon) : ?>
<span class="icon-image ms-1" title="Has custom marker"></span>
<?php endif; ?>
</th>
<td>
<span style="display:inline-block;width:24px;height:24px;border-radius:4px;background:<?php echo $this->escape($item->color); ?>;vertical-align:middle;"></span>
<code class="ms-1"><?php echo $this->escape($item->color); ?></code>
</td>
<td class="d-none d-md-table-cell">
<?php echo $item->parent_title ? $this->escape($item->parent_title) : '—'; ?>
</td>
<td class="d-none d-md-table-cell text-center">
<?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>
</form>
@@ -0,0 +1,66 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
HTMLHelper::_('behavior.formvalidator');
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Category\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&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]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', 'Details'); ?>
<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'); ?>
<?php echo $this->form->renderField('id'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', 'Appearance & Marker'); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('color'); ?>
<?php echo $this->form->renderField('marker_icon'); ?>
<?php echo $this->form->renderField('image'); ?>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h5>Marker Preview</h5>
<p class="text-muted">The color swatch shows how this category's markers will appear on the map. Upload a custom SVG/PNG marker icon to override the default pin.</p>
<div id="marker-preview" style="width:40px;height:40px;border-radius:50%;background:var(--preview-color, #3b82f6);margin:1em auto;"></div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var colorField = document.getElementById('jform_color');
var preview = document.getElementById('marker-preview');
if (colorField && preview) {
preview.style.background = colorField.value || '#3b82f6';
colorField.addEventListener('input', function() {
preview.style.background = this.value;
});
}
});
</script>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1,298 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$locationFields = [
'' => '— Skip —',
'title' => 'Title *',
'address' => 'Address',
'city' => 'City',
'state' => 'State',
'postcode' => 'Postal Code',
'country' => 'Country',
'latitude' => 'Latitude',
'longitude' => 'Longitude',
'phone' => 'Phone',
'email' => 'Email',
'website' => 'Website',
'hours' => 'Hours',
'description' => 'Description',
'video_url' => 'Video URL',
];
?>
<div id="import-step1">
<form id="importUploadForm" enctype="multipart/form-data">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE'); ?></h3>
</div>
<div class="card-body">
<p>Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.</p>
<div class="mb-3">
<label for="import_file" class="form-label"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?></label>
<input type="file" class="form-control" id="import_file" name="import_file" accept=".csv" required />
</div>
<div class="mb-3">
<label class="form-label">CSV Delimiter</label>
<select id="csv_delimiter" class="form-select" style="width:auto;">
<option value="," selected>Comma (,)</option>
<option value=";">Semicolon (;)</option>
<option value=" ">Tab</option>
</select>
</div>
<div class="row mb-3">
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="geocode" name="geocode" value="1" />
<label class="form-check-label" for="geocode"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE'); ?></label>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing" value="1" />
<label class="form-check-label" for="update_existing"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING'); ?></label>
</div>
</div>
</div>
<button type="button" id="parseBtn" class="btn btn-outline-primary">
<span class="icon-arrow-right" aria-hidden="true"></span> Parse &amp; Map Columns
</button>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT'); ?></h4></div>
<div class="card-body">
<p>Supports CSV with any column order. Map your columns in step 2.</p>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=sampledata.download&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-secondary">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE'); ?>
</a>
</div>
</div>
</div>
</div>
</form>
</div>
<div id="import-step2" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 2: Map Columns</h3></div>
<div class="card-body">
<p>Match each CSV column to a location field. Columns that don't match will be skipped.</p>
<table class="table table-sm" id="columnMapTable">
<thead><tr><th>CSV Column</th><th>Sample Data</th><th>Maps To</th></tr></thead>
<tbody></tbody>
</table>
<button type="button" id="previewBtn" class="btn btn-outline-primary">
<span class="icon-eye" aria-hidden="true"></span> Preview Import
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="document.getElementById('import-step1').style.display='';document.getElementById('import-step2').style.display='none';">
Back
</button>
</div>
</div>
</div>
<div id="import-step3" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 3: Preview &amp; Import</h3></div>
<div class="card-body">
<div id="previewSummary" class="alert alert-info mb-3"></div>
<div style="max-height:400px;overflow:auto;">
<table class="table table-sm table-striped" id="previewTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=import.execute'); ?>"
method="post" id="importExecuteForm" enctype="multipart/form-data">
<input type="hidden" name="import_file" id="importFileHidden" />
<input type="hidden" name="column_map" id="columnMapHidden" />
<input type="hidden" name="geocode" id="geocodeHidden" value="0" />
<input type="hidden" name="update_existing" id="updateHidden" value="0" />
<?php echo HTMLHelper::_('form.token'); ?>
<button type="submit" class="btn btn-primary mt-3">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT'); ?>
</button>
<button type="button" class="btn btn-outline-secondary ms-2 mt-3" onclick="document.getElementById('import-step2').style.display='';document.getElementById('import-step3').style.display='none';">
Back to Mapping
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var parsedHeaders = [];
var parsedRows = [];
var fieldOptions = <?php echo json_encode($locationFields); ?>;
// Auto-detect common column name mappings
var autoMap = {
'name': 'title', 'store': 'title', 'location': 'title', 'title': 'title',
'address': 'address', 'street': 'address', 'addr': 'address',
'city': 'city', 'town': 'city',
'state': 'state', 'province': 'state', 'region': 'state',
'zip': 'postcode', 'zipcode': 'postcode', 'postal': 'postcode', 'postcode': 'postcode',
'country': 'country',
'lat': 'latitude', 'latitude': 'latitude',
'lng': 'longitude', 'lon': 'longitude', 'longitude': 'longitude',
'phone': 'phone', 'tel': 'phone', 'telephone': 'phone',
'email': 'email', 'mail': 'email',
'website': 'website', 'url': 'website', 'web': 'website',
'hours': 'hours', 'opening': 'hours',
'description': 'description', 'desc': 'description', 'about': 'description',
'video': 'video_url'
};
document.getElementById('parseBtn').addEventListener('click', function() {
var fileInput = document.getElementById('import_file');
if (!fileInput.files.length) { alert('Please select a CSV file.'); return; }
var reader = new FileReader();
reader.onload = function(e) {
var delimiter = document.getElementById('csv_delimiter').value;
var lines = e.target.result.split('\n').filter(function(l) { return l.trim(); });
if (lines.length < 2) { alert('CSV must have a header row and at least one data row.'); return; }
parsedHeaders = parseCSVLine(lines[0], delimiter);
parsedRows = [];
for (var i = 1; i < Math.min(lines.length, 11); i++) {
parsedRows.push(parseCSVLine(lines[i], delimiter));
}
// Build column mapping table
var tbody = document.querySelector('#columnMapTable tbody');
tbody.textContent = '';
parsedHeaders.forEach(function(header, idx) {
var tr = document.createElement('tr');
var tdHeader = document.createElement('td');
tdHeader.textContent = header;
var tdSample = document.createElement('td');
tdSample.textContent = (parsedRows[0] && parsedRows[0][idx]) || '';
tdSample.style.color = '#6b7280';
var tdSelect = document.createElement('td');
var select = document.createElement('select');
select.className = 'form-select form-select-sm column-map-select';
select.dataset.index = idx;
Object.keys(fieldOptions).forEach(function(val) {
var opt = document.createElement('option');
opt.value = val;
opt.textContent = fieldOptions[val];
// Auto-detect
var norm = header.toLowerCase().replace(/[^a-z]/g, '');
if (autoMap[norm] === val) opt.selected = true;
select.appendChild(opt);
});
tdSelect.appendChild(select);
tr.appendChild(tdHeader);
tr.appendChild(tdSample);
tr.appendChild(tdSelect);
tbody.appendChild(tr);
});
document.getElementById('import-step1').style.display = 'none';
document.getElementById('import-step2').style.display = '';
};
reader.readAsText(fileInput.files[0]);
});
document.getElementById('previewBtn').addEventListener('click', function() {
var mapping = {};
document.querySelectorAll('.column-map-select').forEach(function(sel) {
if (sel.value) mapping[sel.dataset.index] = sel.value;
});
var mappedFields = Object.values(mapping);
if (!mappedFields.includes('title')) { alert('You must map at least the Title column.'); return; }
// Build preview table
var thead = document.querySelector('#previewTable thead');
var tbody = document.querySelector('#previewTable tbody');
thead.textContent = '';
tbody.textContent = '';
var headerRow = document.createElement('tr');
mappedFields.forEach(function(f) {
var th = document.createElement('th');
th.textContent = fieldOptions[f] || f;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
var valid = 0, warnings = 0;
parsedRows.forEach(function(row) {
var tr = document.createElement('tr');
var hasTitle = false;
Object.keys(mapping).forEach(function(idx) {
var td = document.createElement('td');
var val = row[parseInt(idx)] || '';
td.textContent = val;
if (mapping[idx] === 'title' && val) hasTitle = true;
if (mapping[idx] === 'title' && !val) { td.style.background = '#fee2e2'; }
tr.appendChild(td);
});
if (hasTitle) valid++; else warnings++;
tbody.appendChild(tr);
});
var summary = document.getElementById('previewSummary');
summary.textContent = 'Showing first ' + parsedRows.length + ' rows. ' + valid + ' valid, ' + warnings + ' with issues.';
document.getElementById('columnMapHidden').value = JSON.stringify(mapping);
document.getElementById('geocodeHidden').value = document.getElementById('geocode').checked ? '1' : '0';
document.getElementById('updateHidden').value = document.getElementById('update_existing').checked ? '1' : '0';
document.getElementById('import-step2').style.display = 'none';
document.getElementById('import-step3').style.display = '';
});
// Reattach file to the execute form
document.getElementById('importExecuteForm').addEventListener('submit', function(e) {
var fileInput = document.getElementById('import_file');
if (fileInput.files.length) {
var clone = fileInput.cloneNode(true);
clone.style.display = 'none';
this.appendChild(clone);
}
});
function parseCSVLine(line, delimiter) {
var result = [];
var current = '';
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
var c = line[i];
if (c === '"') { inQuotes = !inQuotes; }
else if (c === delimiter && !inQuotes) { result.push(current.trim()); current = ''; }
else { current += c; }
}
result.push(current.trim());
return result;
}
});
</script>
@@ -0,0 +1,158 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
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\MokoJoomStoreLocator\Administrator\View\Location\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('keepalive')
->useScript('form.validate');
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('title'); ?>
<?php echo $this->form->renderField('alias'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
<?php echo $this->form->renderField('id'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('address'); ?>
<?php echo $this->form->renderField('city'); ?>
<?php echo $this->form->renderField('state'); ?>
<?php echo $this->form->renderField('postcode'); ?>
<?php echo $this->form->renderField('country'); ?>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES'); ?></h4>
<small class="text-muted">Click the map to set coordinates</small>
</div>
<div class="card-body">
<div id="coordinate-picker-map" style="height: 300px; border: 1px solid #dee2e6; border-radius: 4px;"></div>
<div class="row mt-3">
<div class="col-6">
<?php echo $this->form->renderField('latitude'); ?>
</div>
<div class="col-6">
<?php echo $this->form->renderField('longitude'); ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'contact', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('phone'); ?>
<?php echo $this->form->renderField('email'); ?>
<?php echo $this->form->renderField('website'); ?>
</div>
<div class="col-lg-6">
<?php echo $this->form->renderField('hours'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'image', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE')); ?>
<?php echo $this->form->renderField('image'); ?>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var latField = document.getElementById('jform_latitude');
var lngField = document.getElementById('jform_longitude');
var lat = parseFloat(latField.value) || 39.8283;
var lng = parseFloat(lngField.value) || -98.5795;
var zoom = (latField.value && lngField.value) ? 15 : 4;
var map = L.map('coordinate-picker-map').setView([lat, lng], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
var marker = null;
if (latField.value && lngField.value) {
marker = L.marker([lat, lng]).addTo(map);
}
map.on('click', function(e) {
var clickLat = e.latlng.lat.toFixed(8);
var clickLng = e.latlng.lng.toFixed(8);
latField.value = clickLat;
lngField.value = clickLng;
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng).addTo(map);
}
});
// Update marker when fields change manually
function updateMarkerFromFields() {
var newLat = parseFloat(latField.value);
var newLng = parseFloat(lngField.value);
if (!isNaN(newLat) && !isNaN(newLng)) {
var latlng = L.latLng(newLat, newLng);
if (marker) {
marker.setLatLng(latlng);
} else {
marker = L.marker(latlng).addTo(map);
}
map.setView(latlng, 15);
}
}
latField.addEventListener('change', updateMarkerFromFields);
lngField.addEventListener('change', updateMarkerFromFields);
// Fix map rendering in tabs
document.querySelector('[data-bs-target="#address"]')?.addEventListener('shown.bs.tab', function() {
map.invalidateSize();
});
});
</script>
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
@@ -13,16 +13,14 @@ use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Locations\HtmlView $this */
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
@@ -56,33 +54,31 @@ use Joomla\CMS\Router\Route;
</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">
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=location.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<?php if ($item->alias) : ?>
<div class="small"><?php echo $this->escape($item->alias); ?></div>
<?php endif; ?>
</th>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->city); ?>
</td>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->state); ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($this->items as $i => $item) : ?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=location.edit&id=' . $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<div class="small"><?php echo Text::_('JFIELD_ALIAS_LABEL') . ': ' . $this->escape($item->alias); ?></div>
</th>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->city); ?>
</td>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->state); ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- =========================================================================
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
=========================================================================
FILE INFORMATION
DEFGROUP: MokoJoomStoreLocator
INGROUP: com_mokojoomstorelocator
PATH: src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml
VERSION: 01.00.00
BRIEF: Component manifest for the store locator component
=========================================================================
-->
<extension type="component" method="upgrade">
<name>Moko Store Locator</name>
<version>00.00.01</version>
<creationDate>2026-05-22</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>A store locator component for managing and displaying location listings.</description>
<namespace path="src">Moko\Component\MokoJoomStoreLocator</namespace>
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
</sql>
</install>
<uninstall>
<sql>
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
n <update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site">
<folder>css</folder>
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<administration>
<files folder="admin">
<folder>forms</folder>
<folder>css</folder>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu>
<menu link="option=com_mokojoomstorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
<menu link="option=com_mokojoomstorelocator&amp;view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
<menu link="option=com_mokojoomstorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu>
</administration>
<config>
<fields name="params">
<fieldset name="geocoding" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING">
<field
name="geocoder_provider"
type="list"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER"
default="nominatim"
>
<option value="nominatim">OpenStreetMap (Nominatim) — Free</option>
<option value="google">Google Geocoding API</option>
</field>
<field
name="google_api_key"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC"
showon="geocoder_provider:google"
/>
<field
name="auto_geocode"
type="radio"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,198 @@
/* MokoJoomStoreLocator — Responsive site styles
* Copyright (C) 2026 Moko Consulting. All rights reserved.
* License: GPL-3.0-or-later
*/
/* === Location list === */
.mokojoomstorelocator-list {
display: grid;
gap: 1.5rem;
}
.mokojoomstorelocator-location {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.25rem;
transition: box-shadow 0.2s;
}
.mokojoomstorelocator-location:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mokojoomstorelocator-location h3 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
}
.mokojoomstorelocator-location h3 a {
text-decoration: none;
}
.mokojoomstorelocator-address,
.mokojoomstorelocator-phone,
.mokojoomstorelocator-website,
.mokojoomstorelocator-hours,
.mokojoomstorelocator-distance,
.mokojoomstorelocator-directions,
.mokojoomstorelocator-categories-tags {
margin-top: 0.35rem;
font-size: 0.9rem;
}
.mokojoomstorelocator-categories-tags span {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #fff;
margin-right: 4px;
margin-bottom: 4px;
}
/* === Image === */
.mokojoomstorelocator-image img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
/* === Gallery (multi-image) === */
.mokojoomstorelocator-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.mokojoomstorelocator-gallery img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
}
/* === Video embed === */
.mokojoomstorelocator-video {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin-top: 1rem;
border-radius: 6px;
}
.mokojoomstorelocator-video iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
/* === Business hours === */
.mokojoomstorelocator-hours-table {
width: 100%;
font-size: 0.9rem;
border-collapse: collapse;
}
.mokojoomstorelocator-hours-table td {
padding: 3px 8px;
border-bottom: 1px solid #f3f4f6;
}
.mokojoomstorelocator-hours-table td:first-child {
font-weight: 600;
width: 100px;
}
.mokojoomstorelocator-open-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.mokojoomstorelocator-open-badge--open {
background: #dcfce7;
color: #166534;
}
.mokojoomstorelocator-open-badge--closed {
background: #fee2e2;
color: #991b1b;
}
/* === Responsive === */
@media (min-width: 768px) {
.mokojoomstorelocator-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767px) {
.com-mokojoomstorelocator-location .row {
flex-direction: column;
}
.com-mokojoomstorelocator-location .col-lg-5,
.com-mokojoomstorelocator-location .col-lg-7 {
width: 100%;
}
/* Click-to-call on mobile */
a[href^="tel:"] {
display: inline-block;
padding: 6px 16px;
background: #3b82f6;
color: #fff;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
}
}
/* === Print === */
@media print {
.mokojoomstorelocator-directions,
.mod-mokojoomstorelocator-search,
.mod-mokojoomstorelocator-map,
.mokojoomstorelocator-video,
.btn,
nav,
footer {
display: none !important;
}
.mokojoomstorelocator-location {
break-inside: avoid;
border: 1px solid #ccc;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.com-mokojoomstorelocator-location {
font-size: 12pt;
}
.com-mokojoomstorelocator-location .col-lg-5 {
display: none;
}
.mokojoomstorelocator-print-map {
display: block !important;
max-width: 300px;
}
}
.mokojoomstorelocator-print-btn {
cursor: pointer;
}
.mokojoomstorelocator-print-map {
display: none;
}
@@ -0,0 +1,39 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "com_mokojoomstorelocator",
"version": "1.0.0",
"description": "Web assets for MokoJoomStoreLocator",
"license": "GPL-3.0-or-later",
"assets": [
{
"name": "com_mokojoomstorelocator.site",
"type": "style",
"uri": "components/com_mokojoomstorelocator/css/storelocator.css"
},
{
"name": "com_mokojoomstorelocator.leaflet",
"type": "script",
"uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
"attributes": { "crossorigin": "" }
},
{
"name": "com_mokojoomstorelocator.leaflet.css",
"type": "style",
"uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
"attributes": { "crossorigin": "" }
},
{
"name": "com_mokojoomstorelocator.markercluster",
"type": "script",
"uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js",
"dependencies": ["com_mokojoomstorelocator.leaflet"],
"attributes": { "crossorigin": "" }
},
{
"name": "com_mokojoomstorelocator.markercluster.css",
"type": "style",
"uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css",
"attributes": { "crossorigin": "" }
}
]
}

Some files were not shown because too many files have changed in this diff Show More