Compare commits

..

213 Commits

Author SHA1 Message Date
gitea-actions[bot] 55b8a474e0 chore(version): pre-release bump to 01.01.01-dev [skip ci] 2026-06-28 20:07:29 +00:00
gitea-actions[bot] 34294f98a2 chore(version): auto-bump patch 01.00.40-dev [skip ci] 2026-06-28 20:07:15 +00:00
jmiller 4b116f5eee docs: update changelog with pretty display names entry
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
Authored-by: Moko Consulting
2026-06-28 15:06:57 -05:00
gitea-actions[bot] 84d42f70a1 chore(version): pre-release bump to 01.00.39-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 10m3s
2026-06-28 20:04:56 +00:00
gitea-actions[bot] 9ed2fb1963 chore(version): auto-bump patch 01.00.38-dev [skip ci] 2026-06-28 20:04:38 +00:00
jmiller 9b09d61473 chore: merge remote dev, resolve sync conflicts
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Project CI / Lint & Validate (pull_request) Successful in 17s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 18s
Universal: Auto Version Bump / Version Bump (push) Successful in 19s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 20s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 15:04:16 -05:00
jmiller 42501f0597 chore: resolve merge conflicts with main (workflow docs)
Authored-by: Moko Consulting
2026-06-28 15:03:15 -05:00
gitea-actions[bot] 7f4451628d chore(version): pre-release bump to 01.00.37-dev [skip ci] 2026-06-28 19:55:09 +00:00
gitea-actions[bot] c8f7422996 chore(version): auto-bump patch 01.00.36-dev [skip ci] 2026-06-28 19:54:54 +00:00
jmiller 8dfc1227cb feat: add pretty display names for all extensions in Joomla admin
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 13s
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 12s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 14:54:35 -05:00
gitea-actions[bot] e8d67215b1 chore(version): pre-release bump to 01.00.35-dev [skip ci] 2026-06-28 18:55:48 +00:00
gitea-actions[bot] f7bbddd98d chore(version): pre-release bump to 01.00.34-dev [skip ci] 2026-06-28 18:55:32 +00:00
gitea-actions[bot] 1ece8a006f chore(version): auto-bump patch 01.00.33-dev [skip ci] 2026-06-28 18:55:15 +00:00
jmiller 5ea2fd2b98 fix: make SQL migration 01.00.02 a no-op to prevent install abort
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 10s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 20s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Joomla aborts the entire package install on any SQL error in update
files. DROP COLUMN fails when catid doesn't exist (fresh installs,
or systems where it was already removed). Since install.mysql.sql
already omits catid, no runtime migration is needed.

Authored-by: Moko Consulting
2026-06-28 13:54:45 -05:00
gitea-actions[bot] b6900aec6e chore(version): pre-release bump to 01.00.32-dev [skip ci] 2026-06-28 18:49:53 +00:00
gitea-actions[bot] ecfb7c426d chore(version): auto-bump patch 01.00.31-dev [skip ci] 2026-06-28 18:49:42 +00:00
jmiller 03c9ca53a6 docs: update changelog with license key, XSS fix, SQL compat entries
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 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Universal: Build & Release / Promote to RC (pull_request) Failing after 10s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 39s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Authored-by: Moko Consulting
2026-06-28 13:48:50 -05:00
gitea-actions[bot] 8c2bf7b02c chore(version): pre-release bump to 01.00.30-dev [skip ci] 2026-06-28 18:47:43 +00:00
gitea-actions[bot] 056b339dee chore(version): auto-bump patch 01.00.29-dev [skip ci] 2026-06-28 18:47:35 +00:00
jmiller 58f3ac96d9 feat: add license key warning and download key preservation
Universal: Auto Version Bump / Version Bump (push) Successful in 17s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Save/restore the download key (dlid) across package upgrades so users
don't lose their license key. Show a warning with direct edit link
when no license key is configured.

Mirrors the pattern from MokoSuiteCross.

Authored-by: Moko Consulting
2026-06-28 13:46:31 -05:00
gitea-actions[bot] 086c50e150 chore(version): pre-release bump to 01.00.28-dev [skip ci] 2026-06-28 18:08:49 +00:00
gitea-actions[bot] 3487072b8a chore(version): pre-release bump to 01.00.27-dev [skip ci] 2026-06-28 18:08:38 +00:00
gitea-actions[bot] edc6bbf62c chore(version): auto-bump patch 01.00.26-dev [skip ci] 2026-06-28 18:08:29 +00:00
jmiller 8b42c016a8 fix: remove IF EXISTS syntax from SQL migration for MySQL 5.7 compat
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Joomla's SQL update runner doesn't support DELIMITER or stored
procedures. DROP COLUMN IF EXISTS is MySQL 8.0.13+ only. Plain
DROP COLUMN is safe here because update files only run on upgrades
from versions that had the catid column.

Authored-by: Moko Consulting
2026-06-28 13:08:14 -05:00
gitea-actions[bot] 41875e7878 chore(version): pre-release bump to 01.00.25-dev [skip ci] 2026-06-28 17:53:42 +00:00
gitea-actions[bot] 797101474a chore(version): pre-release bump to 01.00.24-dev [skip ci] 2026-06-28 16:24:59 +00:00
gitea-actions[bot] dccdb88617 chore(version): auto-bump patch 01.00.23-dev [skip ci] 2026-06-28 16:24:51 +00:00
jmiller 2897a1ceba fix: escape location title in detail map popup to prevent XSS
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
Use DOM-based textContent instead of raw string in Leaflet bindPopup()
to prevent HTML injection via location titles.

Authored-by: Moko Consulting
2026-06-28 11:24:31 -05:00
gitea-actions[bot] 926b4c7576 chore(version): pre-release bump to 01.00.22-dev [skip ci] 2026-06-28 16:23:11 +00:00
gitea-actions[bot] 80cefe1624 chore(version): auto-bump patch 01.00.21-dev [skip ci] 2026-06-28 16:23:03 +00:00
jmiller 32b541597a fix: resolve all open issues — detail map, clustering, CSP, GROUP BY, cleanup (#34 #57 #58 #59 #60 #61)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
- Add Leaflet map to location detail page with marker and popup (#57)
- Implement Leaflet.markercluster with toggleable module parameter (#61)
- Convert inline <script> to $wa->addInlineScript() for CSP nonce support (#34)
- Replace category INNER JOIN with EXISTS subquery for ONLY_FULL_GROUP_BY compat (#59)
- Add delete() override to LocationTable and CategoryTable for junction cleanup (#60)
- Drop dead catid column and idx_catid index via SQL update 01.00.02 (#58)
- Update CHANGELOG and README

Authored-by: Moko Consulting
2026-06-28 11:22:40 -05:00
gitea-actions[bot] 85a3566ae3 chore(version): pre-release bump to 01.00.20-dev [skip ci] 2026-06-28 08:08:05 +00:00
gitea-actions[bot] 1b177f267d chore(version): auto-bump patch 01.00.19-dev [skip ci] 2026-06-28 08:07:56 +00:00
jmiller 99df65b66f chore: sync GOVERNANCE.md from Template-Joomla
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Authored-by: Moko Consulting
2026-06-28 07:58:38 +00:00
jmiller a11ec9b73a chore: sync pr-metadata-check.yml from Template-Joomla 2026-06-28 07:47:40 +00:00
jmiller 2fe10deedf chore: sync SECURITY.md from Template-Joomla 2026-06-28 07:46:14 +00:00
jmiller cb37757087 chore: sync GOVERNANCE.md from Template-Joomla 2026-06-28 07:42:41 +00:00
jmiller 99f738b39c chore: sync CONTRIBUTING.md from Template-Joomla 2026-06-28 07:40:56 +00:00
jmiller d6bde53f96 chore: sync CODE_OF_CONDUCT.md from Template-Joomla 2026-06-28 07:37:50 +00:00
jmiller ad459ba54b chore: sync composer.json from Template-Joomla 2026-06-28 07:35:51 +00:00
jmiller 6f5f5913e9 chore: sync phpstan.neon from Template-Joomla 2026-06-28 07:34:32 +00:00
jmiller 8a231b00af chore: sync .editorconfig from Template-Joomla 2026-06-28 07:34:00 +00:00
gitea-actions[bot] 1d600184b1 chore(version): pre-release bump to 01.00.18-dev [skip ci] 2026-06-28 03:44:05 +00:00
gitea-actions[bot] 45661df50e chore(version): pre-release bump to 01.00.17-dev [skip ci] 2026-06-28 03:10:09 +00:00
gitea-actions[bot] 0be86aec5a chore(version): auto-bump patch 01.00.16-dev [skip ci] 2026-06-28 03:10:01 +00:00
jmiller 7835e77920 fix: rename plugin manifest to match element name
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Joomla's autoloader namespace scanner expects plugin manifests to be
named {element}.xml (e.g. mokosuitestorelocator.xml), not the full
prefixed name (plg_webservices_mokosuitestorelocator.xml). Without the
correct filename, the PSR-4 namespace mapping is not registered in
autoload_psr4.php, causing "Class not found" errors.

Pattern confirmed from MokoSuiteClient's webservices plugin.

Authored-by: Moko Consulting
2026-06-27 22:09:48 -05:00
gitea-actions[bot] 72949fefa5 chore(version): pre-release bump to 01.00.15-dev [skip ci] 2026-06-28 02:59:59 +00:00
gitea-actions[bot] 6c83e5530f chore(version): auto-bump patch 01.00.14-dev [skip ci] 2026-06-28 02:59:49 +00:00
jmiller b75677a6ec fix: add plugin attribute to webservices manifest files element
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Joomla's PluginAdapter requires plugin="xxx" on a <files> child to
determine filesystem deployment path and extension element. Without it,
plugin files are not deployed and the element field is empty in
#__extensions. Pattern confirmed from MokoSuiteClient.

Authored-by: Moko Consulting
2026-06-27 21:59:35 -05:00
gitea-actions[bot] 5f8ab19abd chore(version): pre-release bump to 01.00.13-dev [skip ci] 2026-06-28 02:07:04 +00:00
gitea-actions[bot] d9460d3f07 chore(version): pre-release bump to 01.00.12-dev [skip ci] 2026-06-28 00:27:09 +00:00
gitea-actions[bot] 8a9a3851e0 chore(version): auto-bump patch 01.00.11-dev [skip ci] 2026-06-28 00:26:59 +00:00
jmiller 36eb55758e fix: add module attribute to services folder and hardcode package description
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Joomla's ModuleAdapter requires the `module` attribute on a <files> child
to identify the module element. Without it, install fails with "No module
file specified" even when namespace and services/provider.php are present.
Pattern confirmed against MokoSuiteClient modules.

Also hardcodes the package description since packages don't load language
files during install, causing the raw key to display.

Authored-by: Moko Consulting
2026-06-27 19:26:44 -05:00
jmiller 57e106fff7 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-27 20:44:46 +00:00
gitea-actions[bot] bb30e0cd20 chore(version): pre-release bump to 01.00.10-dev [skip ci] 2026-06-27 20:40:46 +00:00
gitea-actions[bot] 5ccc0246bb chore(version): auto-bump patch 01.00.09-dev [skip ci] 2026-06-27 20:40:35 +00:00
jmiller e16e15dc00 fix: align dlid and updateservers with MokoSuiteClient pattern
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Move <dlid> to right after <description> (before <scriptfile>) and add
priority="1" to <server> element, matching the MokoSuiteClient manifest
structure so Joomla properly registers the download key field.

Authored-by: Moko Consulting
2026-06-27 15:40:19 -05:00
gitea-actions[bot] 7c51396d8d chore(version): pre-release bump to 01.00.08-dev [skip ci] 2026-06-27 20:35:46 +00:00
gitea-actions[bot] 4e9e698d2c chore(version): auto-bump patch 01.00.07-dev [skip ci] 2026-06-27 20:35:36 +00:00
jmiller 81854440de fix: move dlid before updateservers so Joomla registers download key
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
The <dlid> element was placed after </updateservers>, causing Joomla's
installer to skip it when registering the update site. Moving it before
<updateservers> ensures the extra_query field is populated in the
#__update_sites table.

Authored-by: Moko Consulting
2026-06-27 15:35:24 -05:00
gitea-actions[bot] 569f907512 chore(version): pre-release bump to 01.00.06-dev [skip ci] 2026-06-27 20:24:14 +00:00
gitea-actions[bot] 4258fd1d4f chore(version): auto-bump patch 01.00.05-dev [skip ci] 2026-06-27 20:23:59 +00:00
jmiller 3efd423e74 fix: add module services/provider.php and fix manifest packaging
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
Both modules were missing services/provider.php — the Joomla 5 DI
entry point that registers the ModuleDispatcherFactory. Without it,
Joomla falls back to a mod_*.php entry point file that doesn't exist,
causing "No module file specified" on install.

Also moves access.xml into admin/ and adds it to the component
manifest so it gets included in the package ZIP.

Authored-by: Moko Consulting
2026-06-27 15:23:31 -05:00
gitea-actions[bot] f0d8c7ecbf chore(version): pre-release bump to 01.00.04-dev [skip ci] 2026-06-27 20:19:25 +00:00
gitea-actions[bot] f469aa7e64 chore(version): pre-release bump to 01.00.03-dev [skip ci] 2026-06-27 19:44:16 +00:00
gitea-actions[bot] 1c443be9ea chore(version): auto-bump patch 01.00.02-dev [skip ci] 2026-06-27 19:44:07 +00:00
jmiller 6426fee428 feat: v1.2 multi-category, REST API, ACL, security hardening (#1, #2, #29, #30, #31, #34, #48)
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 10s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 37s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Multi-category support with parent/child hierarchy, junction table,
admin CRUD, and per-category custom marker icons on the Leaflet map.

REST API via Web Services plugin with JSON:API endpoints. ACL
permissions via access.xml. SQL update schema for safe upgrades.

Shop integration bridge (LocationBridgeHelper, LocationSavedEvent).
Security: CSV formula injection prevention, MIME validation, file size
limit, ORDER BY allowlist, map height CSS regex validation.

Authored-by: Moko Consulting
2026-06-27 14:40:02 -05:00
Jonathan Miller cf495cd8ce feat: FocalPoint (Shack Locations) migration import
Add one-click import from installed FocalPoint/Shack Locations component:
- Reads #__focalpoint_locations table directly via Joomla DB layer
- Parses customfieldsdata JSON for email, website, hours, phone
- Maps FocalPoint schema to MokoSuiteStoreLocator fields
- Copies coordinates (DECIMAL 10,6 → 10,8), published state, ordering
- Combines description + fulldescription into single description field
- Import button on admin Import view with CSRF + ACL protection
- Graceful handling: checks table exists, reports per-row errors

Field mapping:
  FocalPoint.title → title
  FocalPoint.address → address (single field, no city/state split)
  FocalPoint.latitude/longitude → latitude/longitude
  FocalPoint.phone → phone
  FocalPoint.customfieldsdata.email → email
  FocalPoint.customfieldsdata.website → website
  FocalPoint.customfieldsdata.hours → hours
  FocalPoint.state → published

Authored-by: Moko Consulting
2026-06-27 14:40:01 -05:00
gitea-actions[bot] 6e20567240 chore(version): pre-release bump to 01.00.01-dev [skip ci] 2026-06-27 14:40:00 -05:00
Jonathan Miller 97fb560864 chore: align version format to MokoStandards XX.YY.ZZ (01.00.00)
mokocli tools require two-digit zero-padded version groups (01.00.00)
per MokoStandards convention. Fixes auto-release pipeline failure.

Changed in: pkg manifest, component manifest, both module manifests,
and CHANGELOG.md.

Authored-by: Moko Consulting
2026-06-27 14:40:00 -05:00
Jonathan Miller 753c6336be chore: sync workflows from Template-Joomla (metadata first-class fields)
Pull latest workflow versions from MokoConsulting/Template-Joomla.
These use the updated mokocli tools with manifest metadata as
first-class fields instead of README FILE INFORMATION blocks.

Updated: ci-joomla.yml, pre-release.yml, workflow-sync-trigger.yml
Added: version-set.yml
Removed: deploy-manual.yml, update-server.yml, composer-publish.yml
  (superseded by auto-release.yml pipeline)

Authored-by: Moko Consulting
2026-06-27 14:39:59 -05:00
jmiller 7a86ada1fa chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-27 05:32:28 +00:00
jmiller 37f5a99e99 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-27 00:49:24 +00:00
jmiller e1bb1ca99d chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-25 19:46:56 +00:00
jmiller d61bb1446a chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-25 19:46:55 +00:00
jmiller be622219df chore: sync ci-issue-reporter.yml from Template-Generic [skip ci] 2026-06-25 19:46:53 +00:00
jmiller b3d5ee4f5d chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-25 17:11:49 +00:00
jmiller 90f6ebf0fa chore: sync version-set.yml from Template-Generic [skip ci] 2026-06-25 17:11:48 +00:00
jmiller adef7ec7a7 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-25 17:11:48 +00:00
jmiller 05c3feff9c chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-25 17:11:47 +00:00
jmiller 3422258218 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-25 17:11:46 +00:00
jmiller cacb997162 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-25 17:11:46 +00:00
jmiller 8084fc60cc chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-25 17:11:45 +00:00
jmiller 2b18dcc72c chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-25 17:11:45 +00:00
jmiller 4ee79efffe chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-25 17:11:44 +00:00
jmiller 3453fcb590 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-25 17:11:44 +00:00
jmiller 2fc3a9d2b4 chore: sync version-set.yml from Template-Generic [skip ci] 2026-06-24 11:51:14 +00:00
jmiller dc3d85d55b chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-24 11:51:11 +00:00
jmiller 4a2a002b32 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-23 18:46:27 +00:00
jmiller 421282f32c chore: remove security-audit.yml -- handled by MokoGitea 2026-06-23 18:05:42 +00:00
jmiller 3fa7508794 chore: remove deploy-manual.yml -- no longer needed 2026-06-23 18:00:14 +00:00
jmiller 6dd93059cc chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-23 17:51:16 +00:00
jmiller 0836f2f21d chore: remove deprecated .mokogitea/workflows/composer-publish.yml [skip ci] 2026-06-23 17:38:10 +00:00
jmiller e3e3e2a1f7 chore: remove deprecated .mokogitea/workflows/update-server.yml [skip ci] 2026-06-23 17:38:06 +00:00
jmiller b6a24d7952 chore: remove deprecated .mokogitea/workflows/deploy-manual.yml [skip ci] 2026-06-23 17:38:03 +00:00
jmiller cc70e4abc2 feat: v1.1 competitive parity — proximity, directions, geocoding, CSV import (#55) 2026-06-23 17:26:45 +00:00
Jonathan Miller c56f3473b1 fix: review round 2 — geocode 0-coord, tel: sanitization, Factory ACL
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Successful in 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 17s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Fix geocoding trigger: use isset+is_numeric instead of !empty for
  coordinate detection (same 0.0 bug pattern as Haversine fix)
- Sanitize tel: href to digits/+/-/() only (prevents URI injection)
- Use Factory::getApplication() for ACL check (consistent with codebase)

Authored-by: Moko Consulting
2026-06-23 12:26:18 -05:00
Jonathan Miller ddb25fa99f chore: remove legacy source/src/ directory (old MokoJoom naming)
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Joomla: Extension CI / Lint & Validate (pull_request) Successful in 34s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Delete 26 files from the pre-rename MokoJoomStoreLocator structure.
These were superseded by source/packages/ with MokoSuite naming.
No workflow or manifest references remain to the old paths.

Closes #52

Authored-by: Moko Consulting
2026-06-23 12:23:05 -05:00
Jonathan Miller c8a3c58495 fix: code review fixes — security, 0-coord bug, BOM, ACL
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Successful in 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 52s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Security:
- ACL check (core.create) in ImportController before processing
- File extension validation (.csv/.txt only) on upload
- Website href restricted to http/https scheme (prevents javascript: XSS)

Bug fixes:
- Fix 0.0 coordinate rejection: use null checks instead of != 0.0
  (coordinates at equator/prime meridian are valid locations)
- Fix Haversine guard using !== null instead of PHP truthiness
- Fix geocoding result check: isset+is_numeric instead of !empty
- Strip UTF-8 BOM from first CSV header (fixes Excel-generated imports)
- Cap radius at 25000 to prevent unreasonable distance queries

Authored-by: Moko Consulting
2026-06-23 12:07:03 -05:00
Jonathan Miller 7ef9a23ef8 feat: v1.1 competitive parity — proximity search, directions, geocoding, CSV import
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: Build & Release / Promote to RC (pull_request) Failing after 15s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Successful in 38s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Haversine proximity search:
- LocationsModel filters by distance using Haversine formula
- populateState captures lat/lng/radius/radius_unit from search form
- Distance-sorted results when proximity filter is active
- Hidden radius_unit field added to search module form

Get Directions:
- Google Maps directions link on location detail page (no API key)
- Directions link in Leaflet popup markers

Auto-geocoding:
- LocationModel::save() override auto-geocodes empty coordinates
- Calls Nominatim/OSM API when address present but coords missing
- Success/failure messages via Joomla enqueueMessage

CSV Import:
- ImportController handles file upload with CSRF token check
- ImportModel parses CSV via SplFileObject with configurable delimiter
- Auto-detects column headers (title/name, address/street, city, etc.)
- Per-row validation via LocationTable::bind()->check()->store()
- Import view with upload form, delimiter picker, and column help
- Toolbar button and submenu item for import access

Addresses #54

Authored-by: Moko Consulting
2026-06-23 11:50:02 -05:00
jmiller d3e73a0ea4 feat: complete store locator implementation (Phases 1-3) (#53) 2026-06-23 16:10:29 +00:00
Jonathan Miller e26d0ed400 fix: code review fixes, Joomla 5/6 compat, XSS prevention
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Generic: Project CI / Lint & Validate (pull_request) Successful in 34s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Security:
- Fix stored XSS in Leaflet popup — HTML-escape loc.title/address/phone
- Use HTMLHelper::_('content.prepare') for description output

Joomla 5/6 compatibility:
- Bump PHP minimum to 8.2, Joomla minimum to 5.0.0
- script.php implements InstallerScriptInterface with typed signatures
- Restore updateservers and dlid in package manifest
- Update all manifest creationDates to 2026-06-23

Code quality:
- Replace hard-coded English errors with Text::_() language strings
- Add COM_MOKOJOOMSTORELOCATOR_ERROR_* language keys
- Use Text::_() for Locations list toolbar title
- Import missing Text class in LocationTable and Locations HtmlView

Documentation:
- Update CLAUDE.md: MokoSuite naming, source/ paths, PHP 8.2, namespace
- Update README: Joomla 5/6, PHP 8.2+, MySQL 8.0+

Authored-by: Moko Consulting
2026-06-23 11:08:46 -05:00
Jonathan Miller f73e536d05 chore: fix script.php location, update README and changelog for release
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Project CI / Lint & Validate (pull_request) Successful in 33s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Move script.php to source/ (alongside package manifest) with MokoSuite naming
- Update README: map and search now listed as implemented, not planned
- Set changelog version to 1.0.0

Authored-by: Moko Consulting
2026-06-23 11:02:19 -05:00
Jonathan Miller 4894a703c2 feat: wire up map and search modules with Leaflet and geolocation (Phase 3)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Universal: Build & Release / Promote to RC (pull_request) Failing after 7s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 27s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Map module:
- Dispatcher queries published locations with coordinates from DB
- Leaflet.js + OpenStreetMap tile layer integration
- Markers with popup info (name, address, phone)
- Auto-fit bounds to show all markers
- Leaflet CSS/JS via Joomla Web Asset Manager

Search module:
- Dispatcher loads distinct cities/states for dropdown filters
- Builds radius options from module params
- City dropdown filter (toggled by show_city_filter param)
- Radius dropdown with configurable values and unit (miles/km)
- Geolocation "Use My Location" button via browser API
- Hidden lat/lng fields for proximity search passthrough
- New language strings for all search UI elements

Both dispatchers now implement DatabaseAwareInterface for DB access.

Addresses #51, #3, #35

Authored-by: Moko Consulting
2026-06-23 10:45:50 -05:00
Jonathan Miller 07a4fcc0b5 docs: update README with MokoSuite naming and current feature status
Rename from MokoJoomStoreLocator to MokoSuiteStoreLocator, update
package element names, add implemented vs planned feature sections,
add development instructions.

Authored-by: Moko Consulting
2026-06-23 10:34:00 -05:00
Jonathan Miller 4b37489c41 feat: add site frontend with locations list, detail view, and SEF router (Phase 2)
Build the complete public-facing frontend for the store locator:
- Site DisplayController routing to list and detail views
- LocationsModel with search, city, and state filters (published only)
- LocationModel for single location by ID
- Locations list template with Schema.org LocalBusiness markup
- Location detail template with address, contact, hours, map placeholder
- SEF URL router with menu/standard/nomenu rules
- Menu item types: "All Locations" list and "Location Detail" picker
- Site language strings for views and menu items
- Router wired into component extension class and service provider
- .gitignore exception for source/packages/*/site/

Addresses #50

Authored-by: Moko Consulting
2026-06-23 08:49:07 -05:00
Jonathan Miller c415d4813f feat: complete admin CRUD for locations (Phase 1)
Add full admin editing capability for store locations:
- LocationController (FormController) for save/cancel/apply
- LocationsController (AdminController) for bulk publish/unpublish/delete
- Location edit view with tabbed form (Details, Address, Contact)
- Admin list renders data rows with edit links and published toggle
- LocationTable::check() validates title, auto-alias, lat/lng, timestamps
- LocationsModel with populateState(), search/published filters, ordering
- filter_locations.xml with search tools bar
- Language strings for filters, sort options, save messages

Also includes MokoSuite-renamed package scaffolding (source/packages/).
Removes unused Makefile.

Closes #32 — populateState for filter persistence
Closes #33 — filter XML forms for admin list

Authored-by: Moko Consulting
2026-06-23 08:40:02 -05:00
jmiller 72cde2c8ca chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-22 00:35:24 +00:00
jmiller 4628291217 chore: remove unused Makefile - builds handled by CI auto-release 2026-06-21 23:55:24 +00:00
jmiller cf2035abbe Merge pull request 'chore: remove automation directory' (#46) from fix/remove-automation into main 2026-06-21 23:10:34 +00:00
Jonathan Miller 9bffe09a1f chore: remove automation directory
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 10s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 13s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-21 18:03:25 -05:00
jmiller b7c0e70e44 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:03:05 +00:00
jmiller f66228872b chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:52 +00:00
jmiller 3b324feda0 chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:35:14 +00:00
jmiller e55d8d0dea chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:29:04 +00:00
jmiller 5a9bf8257b chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:29:02 +00:00
jmiller 8db35e5d30 ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-21 00:15:05 +00:00
jmiller 2821d07208 ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-21 00:14:36 +00:00
jmiller fd578d543d ci: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-06-21 00:14:11 +00:00
jmiller 331eca3b49 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 23:46:42 +00:00
jmiller 0cdbc677c5 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-20 23:46:41 +00:00
jmiller 716446c9a4 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 23:46:40 +00:00
jmiller 221697b0c7 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-20 22:30:14 +00:00
jmiller cc7baa540d chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-20 22:30:13 +00:00
jmiller efd1d2bcd3 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 22:30:13 +00:00
jmiller 9581355985 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-20 22:30:12 +00:00
jmiller 891cf049f5 ci: sync security-audit.yml from Template-Joomla [skip ci] 2026-06-20 22:26:31 +00:00
jmiller 183fcdb8bb ci: sync repo-health.yml from Template-Joomla [skip ci] 2026-06-20 22:26:02 +00:00
jmiller 15550b975c ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-20 22:25:53 +00:00
jmiller d0cc4c9505 ci: sync pr-check.yml from Template-Joomla [skip ci] 2026-06-20 22:24:46 +00:00
jmiller 36e771963c ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-20 22:22:21 +00:00
jmiller 73bdc3c456 ci: sync cleanup.yml from Template-Joomla [skip ci] 2026-06-20 22:15:35 +00:00
jmiller 0d1473e433 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 21:35:32 +00:00
jmiller e2adb9d964 ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 21:34:02 +00:00
jmiller 68fe793008 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 21:31:34 +00:00
jmiller b98878880d ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 21:28:09 +00:00
jmiller 0acd87ebf8 ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 21:26:57 +00:00
jmiller 02d72c8f34 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-20 20:53:36 +00:00
jmiller 77a401d606 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-20 20:53:34 +00:00
jmiller e8080a22d4 ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 20:35:05 +00:00
jmiller 12ec46e5a6 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 20:32:51 +00:00
jmiller d7b5f4ee8e ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 20:31:53 +00:00
jmiller fd8a6c1380 ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 20:30:59 +00:00
jmiller 26e145afe4 ci: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-20 19:59:08 +00:00
jmiller e38de4578d ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 19:05:59 +00:00
jmiller fc4e421bed ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 19:03:18 +00:00
jmiller 8850cff9c2 ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 19:02:45 +00:00
jmiller ebcfbfd249 ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 19:01:04 +00:00
jmiller cfa66189f7 ci: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-20 18:53:50 +00:00
jmiller 8af7f37dce ci: sync pre-release workflow from Template-Joomla
Generic: Project CI / Lint & Validate (push) Successful in 36s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 18:49:30 +00:00
jmiller aa2ed41dce ci: add Joomla metadata validation workflow for PRs
Generic: Project CI / Lint & Validate (push) Successful in 41s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 18:39:07 +00:00
jmiller 293944a162 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Project CI / Lint & Validate (push) Successful in 5s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 17:16:42 +00:00
jmiller 0276b766dd fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 5s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:16:42 +00:00
jmiller 57b601ea24 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Project CI / Lint & Validate (push) Successful in 5s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:16:41 +00:00
jmiller d87ce687e3 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 5s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:16:40 +00:00
jmiller e627aa885b fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 21s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:16:39 +00:00
jmiller 4144489ecb fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 7s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:16:39 +00:00
jmiller 78f57f4f69 Merge pull request 'refactor: rename src/ to source/' (#45) from fix/rename-src-to-source-v2 into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Successful in 30s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 16:02:33 +00:00
Jonathan Miller 6d7838a817 refactor: rename src/ to source/ for consistency
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 8s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 3s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-20 10:58:04 -05:00
jmiller a8ca52bcc8 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-07 17:58:37 +00:00
jmiller 979fdf9cf0 chore: sync notify.yml from Template-Generic [skip ci] 2026-06-07 17:58:37 +00:00
jmiller ad4b70e8ea chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-07 17:58:36 +00:00
jmiller 201c94b97f chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-07 17:58:36 +00:00
jmiller f6df1b344d chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-07 17:58:35 +00:00
jmiller a2726ad6b9 chore: add dlid and blockChildUninstall to package manifest [skip ci] 2026-06-04 22:02:40 +00:00
jmiller c20df6c97d chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:58:42 +00:00
jmiller 161c191e78 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:41:22 +00:00
jmiller d5afe0b738 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:32:40 +00:00
jmiller eb106e3c60 chore: remove updates.xml [skip ci] 2026-06-04 15:27:12 +00:00
jmiller 932858b2b2 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:19:27 +00:00
jmiller bdd3426dcc feat(update): migrate update server URL to Gitea Pages [skip ci] 2026-06-04 14:33:43 +00:00
jmiller 92c49fa0c3 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:23:50 +00:00
jmiller e46497eca5 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-04 13:47:33 +00:00
Moko Consulting 08f73ac7ae chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:22 +00:00
Moko Consulting 15d55ea7a5 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:21 +00:00
Moko Consulting a8bc86f974 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:21 +00:00
jmiller b75fdeb11b chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:42:37 +00:00
jmiller cd9d1e4603 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:10:39 +00:00
jmiller 1467c093ca chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-05-30 16:02:06 +00:00
jmiller f666c8915c chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-30 15:00:13 +00:00
jmiller 5b75cb836b chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 14:56:42 +00:00
jmiller 1182e98673 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 14:54:45 +00:00
jmiller a3421cc220 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 05:51:51 +00:00
jmiller 5d1e2bb884 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 03:41:41 +00:00
jmiller fb74521468 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 01:15:25 +00:00
jmiller e39143646c chore: add .mokogitea/branch-protection.yml from moko-platform [skip ci] 2026-05-29 10:30:39 +00:00
jmiller fd44c4a967 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-29 10:28:05 +00:00
jmiller bb8f85c407 chore: add .mokogitea/workflows/branch-cleanup.yml from moko-platform [skip ci] 2026-05-29 10:26:28 +00:00
jmiller 569871e105 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-29 10:25:00 +00:00
jmiller c3ad06f533 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-29 10:23:31 +00:00
jmiller 3dcc9fb77c chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:54:14 +00:00
jmiller ba9713230f chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:49:07 +00:00
jmiller 0afbcf9310 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:44:24 +00:00
jmiller 7e25c44122 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:38:29 +00:00
gitea-actions[bot] 3446b87409 feat(ci): add version branch creation on stable release [skip ci] 2026-05-27 02:19:22 +00:00
jmiller ff13f2077e chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:51:23 +00:00
jmiller d4f18570fd chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:50:12 +00:00
jmiller 006bb952fe chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:49:00 +00:00
jmiller 7b7301c929 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:37:33 +00:00
jmiller a972dcc7c5 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:36:06 +00:00
jmiller 58d3c1bdf4 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:25:44 +00:00
jmiller a1fd153542 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:24:29 +00:00
jmiller 8f28f731a7 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:13:50 +00:00
jmiller d61747759e chore(ci): add auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:12:37 +00:00
jmiller 663bc0293c chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 20:13:16 +00:00
jmiller 0e7dea05bc chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 20:11:22 +00:00
jmiller 8329e4a965 chore(ci): add update-server.yml universal workflow [skip ci] 2026-05-26 19:56:56 +00:00
jmiller c60c784665 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 19:36:16 +00:00
jmiller 633baa550a chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 19:36:15 +00:00
jmiller d07cf877fb chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 19:04:36 +00:00
jmiller 05ec3373d2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-26 03:08:02 +00:00
jmiller 76fca03d4e chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 03:06:06 +00:00
jmiller 55d8ff2b67 feat(ci): add issue-branch.yml [skip ci] 2026-05-25 05:12:58 +00:00
105 changed files with 7731 additions and 872 deletions
+2 -1
View File
@@ -113,7 +113,8 @@ releases/
build/
dist/
out/
/site/
site/
!source/packages/*/site/
*.map
*.css.map
*.js.map
-62
View File
@@ -1,62 +0,0 @@
# MokoSuiteStoreLocator
Store locator listing component with coordinating map and search modules for Joomla.
## Quick Reference
| Field | Value |
|---|---|
| **Package** | `pkg_mokosuitestorelocator` |
| **Language** | PHP 8.1+ |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [MokoSuiteStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/wiki) |
## 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` |
## Commands
```bash
make build # Build package ZIP containing all sub-extensions
make lint # Run PHP linter
make validate # Lint + validation checks
make release # Validate + build
make clean # Clean build artifacts
composer install # Install PHP dev dependencies
```
## Architecture
Joomla **package** with component + 2 modules:
- `src/pkg_mokosuitestorelocator.xml` — package manifest
- `src/script.php` — package install/upgrade/uninstall script
- `src/packages/com_mokosuitestorelocator/` — main component
- `admin/` — admin MVC (controllers, models, views, forms, tables, SQL)
- `site/` — frontend MVC (controllers, models, views, templates)
- `src/packages/mod_mokosuitestorelocator_map/` — map display module
- `src/packages/mod_mokosuitestorelocator_search/` — search/filter module
### Database Table
`#__mokosuitestorelocator_locations` — location data (coordinates, address, contact, business hours)
Namespace: `Moko\Component\MokoSuiteStoreLocator`
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
- **Attribution**: `Authored-by: Moko Consulting`
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/mokoplatform/wiki/Home)
## Coding Standards
- PHP 8.1+ minimum
- Joomla table operations: `bind() → check() → store()`, never `save()`
- SPDX license headers on all PHP files
+1 -1
View File
@@ -8,7 +8,7 @@ contact_links:
url: https://mokoconsulting.tech/
about: Get help or ask questions through our website
- name: 📚 MokoStandards Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
about: View our coding standards and best practices
- name: 🔒 Report a Security Vulnerability
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
+251
View File
@@ -0,0 +1,251 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
# +========================================================================+
# | BRANCH PROTECTION SETUP |
# +========================================================================+
# | |
# | Applies protection rules for: main, dev, rc, beta, alpha |
# | |
# | main — Require PR, block rejected reviews, no force push |
# | dev — Allow push, no force push, no delete |
# | rc — Allow push, no force push, no delete |
# | beta — Allow push, no force push, no delete |
# | alpha — Allow push, no force push, no delete |
# | |
# | jmiller has override authority on all branches. |
# | |
# +========================================================================+
name: Branch Protection Setup
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Preview mode (no changes)'
required: false
type: boolean
default: false
repos:
description: 'Comma-separated repo names (empty = all governed repos)'
required: false
type: string
default: ''
env:
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
permissions:
contents: read
jobs:
protect:
name: Apply Branch Protection Rules
runs-on: ubuntu-latest
steps:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
else
# Fetch all org repos
PAGE=1
REPOS=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
REPOS="$REPOS $BATCH"
PAGE=$((PAGE + 1))
done
# Filter out excluded repos
FILTERED=""
for REPO in $REPOS; do
SKIP=false
for EX in $EXCLUDE; do
if [ "$REPO" = "$EX" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" = "false" ]; then
FILTERED="$FILTERED $REPO"
fi
done
REPOS="$FILTERED"
fi
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$REPOS" | wc -w)
echo "📋 Target repos (${COUNT}): $REPOS"
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
REPOS="${{ steps.repos.outputs.repos }}"
SUCCESS=0
FAILED=0
SKIPPED=0
# ── Rule definitions ──────────────────────────────────────
# Only the CI bot (jmiller token) can push directly.
# All human contributors must use PRs.
# Force push disabled on all branches.
RULE_MAIN='{
"rule_name": "main",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"dismiss_stale_approvals": true,
"block_on_rejected_reviews": true,
"block_on_outdated_branch": false,
"priority": 1
}'
RULE_DEV='{
"rule_name": "dev",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 2
}'
RULE_RC='{
"rule_name": "rc",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 3
}'
RULE_BETA='{
"rule_name": "beta",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 4
}'
RULE_ALPHA='{
"rule_name": "alpha",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 5
}'
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
# ── Apply rules to each repo ──────────────────────────────
for REPO in $REPOS; do
echo ""
echo "═══ ${REPO} ═══"
for i in "${!RULES[@]}"; do
RULE="${RULES[$i]}"
NAME="${RULE_NAMES[$i]}"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would apply rule: ${NAME}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Delete existing rule if present (idempotent recreate)
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
curl -sS -o /dev/null -w "" \
-X DELETE \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
# Create rule
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$RULE" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
HTTP=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP" = "201" ]; then
echo " ✅ ${NAME}"
SUCCESS=$((SUCCESS + 1))
else
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
FAILED=$((FAILED + 1))
fi
done
done
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo " ✅ Success: ${SUCCESS}"
echo " ❌ Failed: ${FAILED}"
echo " ⏭️ Skipped: ${SKIPPED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} rule(s) failed to apply"
fi
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGITEA_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"
+189 -46
View File
@@ -4,15 +4,15 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# +=======================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
@@ -21,15 +21,24 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
# +=======================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
types: [opened, synchronize, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
@@ -43,7 +52,7 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_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 }}
@@ -51,12 +60,13 @@ permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
@@ -66,25 +76,25 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokoplatform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokoplatform-api
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/mokoplatform-api/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Rename branch to rc
@@ -92,7 +102,7 @@ jobs:
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
@@ -109,13 +119,47 @@ jobs:
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Release candidate"
# Find the RC release and update its body
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "RC release notes updated from CHANGELOG.md"
fi
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release:
name: Build & Release Pipeline
runs-on: release
@@ -149,50 +193,131 @@ jobs:
fi
echo "No conflict markers found"
- name: Setup mokoplatform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokoplatform-api
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/mokoplatform-api/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level"
id: bump
run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
# Feature/dev branches: bump minor for the new stable release
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;;
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
- name: "Publish stable release"
run: |
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
fi
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable --bump minor --branch main \
--path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update release notes from CHANGELOG.md
- name: "Read published version"
id: version
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
NOTES="Stable release"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
@@ -202,7 +327,7 @@ jobs:
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
@@ -210,6 +335,24 @@ jobs:
echo "Release notes updated from CHANGELOG.md"
fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
@@ -220,7 +363,7 @@ jobs:
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
@@ -249,7 +392,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
@@ -273,7 +416,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
@@ -294,7 +437,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
@@ -320,5 +463,5 @@ jobs:
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+10
View File
@@ -0,0 +1,10 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
+197
View File
@@ -0,0 +1,197 @@
# 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:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
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
@@ -0,0 +1,68 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
on:
workflow_call:
inputs:
gate:
description: "CI gate name (e.g. PR Validation, Repository Health)"
required: true
type: string
details:
description: "Human-readable failure description"
required: true
type: string
severity:
description: "error or warning"
required: false
type: string
default: "error"
workflow:
description: "Workflow name for the issue title"
required: false
type: string
default: ""
secrets:
MOKOGITEA_TOKEN:
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
report:
name: "Report: ${{ inputs.gate }}"
runs-on: ubuntu-latest
steps:
- name: Clone MokoCLI
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
- name: Report CI failure
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
/tmp/mokocli/cli/ci_issue_reporter.sh \
--gate "${{ inputs.gate }}" \
--details "${{ inputs.details }}" \
--severity "${{ inputs.severity }}" \
--workflow "${{ inputs.workflow }}"
+800 -16
View File
@@ -35,25 +35,32 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Clone MokoStandards
- name: Setup mokocli tools
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
echo "mokocli already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
fi
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -124,8 +131,8 @@ jobs:
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags: name, version, author, namespace (Joomla 5+)
for TAG in name version author namespace; do
# Check required tags: name, version, author
for TAG in name version author; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -133,6 +140,19 @@ jobs:
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
# Namespace is required for components/plugins but not packages
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
fi
fi
if [ "${ERRORS}" -gt 0 ]; then
@@ -144,6 +164,75 @@ jobs:
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Update server & packaging checks
continue-on-error: true
run: |
echo "### Update Server & Packaging" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
# Find the extension manifest
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
# 1. Check <updateservers> exists and uses MokoGitea update server
if ! grep -q '<updateservers>' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::Missing \`<updateservers>\` tag — extension will not receive OTA updates"
echo "- **Missing** \`<updateservers>\` — extension will not receive OTA updates" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
SERVER_URL=$(grep -oP '<server[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$SERVER_URL" ]; then
echo "::warning file=${MANIFEST}::\`<updateservers>\` is empty — no server URL defined"
echo "- **Empty** \`<updateservers>\` — no server URL defined" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
elif ! echo "$SERVER_URL" | grep -q 'git\.mokoconsulting\.tech'; then
echo "::warning file=${MANIFEST}::Update server does not use MokoGitea engine: ${SERVER_URL}"
echo "- **Non-MokoGitea update server:** \`${SERVER_URL}\`" >> $GITHUB_STEP_SUMMARY
echo " Expected: \`https://git.mokoconsulting.tech/{org}/{repo}/updates.xml\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- \`<updateservers>\`: MokoGitea engine ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
# 2. Check <dlid> tag exists
if ! grep -q '<dlid' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::Missing \`<dlid>\` tag — download ID authentication is not configured"
echo "- **Missing** \`<dlid>\` — download ID authentication not configured" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- \`<dlid>\`: present ✓" >> $GITHUB_STEP_SUMMARY
fi
# 3. For packages: check <childuninstall> tag
if [ "$EXT_TYPE" = "package" ]; then
if ! grep -q '<childuninstall>' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::Package is missing \`<childuninstall>\` — child extensions will not be removed on uninstall"
echo "- **Missing** \`<childuninstall>\` — child extensions will remain when package is uninstalled" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- \`<childuninstall>\`: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} packaging warning(s).** These won't block CI but should be addressed." >> $GITHUB_STEP_SUMMARY
else
echo "**Update server & packaging checks passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check language files referenced in manifest
run: |
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
@@ -225,14 +314,679 @@ jobs:
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
- name: Check config.xml and access.xml for components
run: |
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find all component manifests (XML with type="component")
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
if [ -z "$COMP_MANIFESTS" ]; then
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $COMP_MANIFESTS; do
COMP_DIR=$(dirname "$MANIFEST")
COMP_NAME=$(basename "$COMP_DIR")
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
# Check access.xml exists
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ACCESS_FILE" ]; then
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
for ACTION in core.admin core.manage; do
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
# Check config.xml exists
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$CONFIG_FILE" ]; then
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SQL schema validation
run: |
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find SQL files in source/htdocs
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$SQL_FILES" ]; then
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $SQL_FILES; do
# Basic syntax check: balanced parentheses, no empty files
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -eq 0 ]; then
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
# Check for common SQL errors
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
done
# Check update SQL files follow version numbering pattern
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$UPDATE_DIR" ]; then
BAD_NAMES=0
for UFILE in "$UPDATE_DIR"/*.sql; do
[ ! -f "$UFILE" ] && continue
BASENAME=$(basename "$UFILE" .sql)
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
BAD_NAMES=$((BAD_NAMES + 1))
fi
done
if [ "$BAD_NAMES" -gt 0 ]; then
ERRORS=$((ERRORS + BAD_NAMES))
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Manifest file references check
run: |
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <filename> references
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILENAMES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <folder> references
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <file> references in package manifests (ZIP files won't exist in source)
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Form XML validation
run: |
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$FORM_FILES" ]; then
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $FORM_FILES; do
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
# Check for valid Joomla form structure
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Deprecated Joomla API check
continue-on-error: true
run: |
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Joomla 3/4 deprecated patterns that break in Joomla 6
PATTERNS=(
'JFactory::'
'JText::'
'JHtml::'
'JRoute::'
'JUri::'
'JLog::'
'JTable::'
'JInput'
'CMSFactory::\$application'
'JApplicationCms'
)
for PATTERN in "${PATTERNS[@]}"; do
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
if [ -n "$HITS" ]; then
COUNT=$(echo "$HITS" | wc -l)
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + COUNT))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
else
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Template output escaping check
continue-on-error: true
run: |
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$TMPL_FILES" ]; then
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $TMPL_FILES; do
# Check for unescaped output: <?= $var ?> or echo $var without escape()
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$UNESCAPED" ]; then
HITS=$(echo "$UNESCAPED" | wc -l)
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
# Check for echo without escaping in template context
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$RAW_ECHO" ]; then
HITS=$(echo "$RAW_ECHO" | wc -l)
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
else
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Namespace consistency check
run: |
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find component/plugin manifests with <namespace> tags
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
if [ -z "$MANIFESTS" ]; then
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $MANIFESTS; do
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$NS_PATH" ] && continue
MANIFEST_DIR=$(dirname "$MANIFEST")
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
# Check PHP files have matching namespace
while IFS= read -r -d '' PHP_FILE; do
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
[ -z "$FILE_NS" ] && continue
# Namespace should start with the manifest namespace path
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SPDX license header check
continue-on-error: true
run: |
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
MISSING=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL=0
while IFS= read -r -d '' FILE; do
TOTAL=$((TOTAL + 1))
if ! head -10 "$FILE" | grep -qi "SPDX"; then
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$MISSING" -gt 0 ]; then
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
else
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Service provider check
run: |
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$PROVIDERS" ]; then
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
else
for FILE in $PROVIDERS; do
# Must return a ServiceProviderInterface
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
fi
# Must have return statement
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Script file reference check
run: |
echo "### Script File Reference" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
SCRIPT_FILE=$(grep -oP '<scriptfile>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$SCRIPT_FILE" ]; then
echo "No \`<scriptfile>\` referenced — skipping." >> $GITHUB_STEP_SUMMARY
elif [ ! -f "${MANIFEST_DIR}/${SCRIPT_FILE}" ]; then
echo "::error file=${MANIFEST}::Manifest references \`<scriptfile>${SCRIPT_FILE}</scriptfile>\` but file does not exist"
echo "- **Missing** \`${SCRIPT_FILE}\` — referenced in \`<scriptfile>\` but not found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${SCRIPT_FILE}\`: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} script file issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Script file reference check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Media folder validation
run: |
echo "### Media Folder Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <media> tag and its folder/filename children
MEDIA_DEST=$(grep -oP '<media[^>]*\bdestination="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
MEDIA_FOLDER=$(grep -oP '<media[^>]*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$MEDIA_DEST" ] && [ -z "$MEDIA_FOLDER" ]; then
echo "No \`<media>\` tag found — skipping." >> $GITHUB_STEP_SUMMARY
else
if [ -n "$MEDIA_FOLDER" ] && [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then
echo "::error file=${MANIFEST}::\`<media folder=\"${MEDIA_FOLDER}\">\` references missing directory"
echo "- **Missing** media folder \`${MEDIA_FOLDER}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- Media folder \`${MEDIA_FOLDER:-(inline)}\`: present ✓" >> $GITHUB_STEP_SUMMARY
# Check child references inside <media> block
if [ -n "$MEDIA_FOLDER" ]; then
MEDIA_FOLDERS=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<folder>\K[^<]+' 2>/dev/null || true)
for F in $MEDIA_FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
echo "- **Missing** media subfolder \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
MEDIA_FILES=$(sed -n '/<media /,/<\/media>/p' "$MANIFEST" | grep -oP '<filename>\K[^<]+' 2>/dev/null || true)
for F in $MEDIA_FILES; do
if [ ! -f "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then
echo "- **Missing** media file \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} media reference issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Media folder validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Target platform check
continue-on-error: true
run: |
echo "### Target Platform Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Check updates.xml for targetplatform if it exists
if [ -f "updates.xml" ]; then
if ! grep -q '<targetplatform' "updates.xml" 2>/dev/null; then
echo "::warning file=updates.xml::No \`<targetplatform>\` found — Joomla updater cannot filter by compatible version"
echo "- **Missing** \`<targetplatform>\` in updates.xml" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- \`<targetplatform>\` in updates.xml: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
# Check manifest for minimum PHP/Joomla version hints
if ! grep -qP '<php_minimum>|targetplatform|joomla.*version' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::No minimum Joomla or PHP version constraint found in manifest"
echo "- **Missing** version constraints (\`<php_minimum>\` or \`<targetplatform>\`)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
echo "- Version constraints in manifest: present ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} target platform warning(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Target platform check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Changelog URL check
continue-on-error: true
run: |
echo "### Changelog URL Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
if ! grep -q '<changelogurl>' "$MANIFEST" 2>/dev/null; then
echo "::warning file=${MANIFEST}::Missing \`<changelogurl>\` — Joomla updater will not display changelogs"
echo "- **Missing** \`<changelogurl>\` — Joomla 4+ shows changelogs in the update manager when this is set" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
else
CHANGELOG_URL=$(grep -oP '<changelogurl>\K[^<]+' "$MANIFEST" | head -1)
echo "- \`<changelogurl>\`: \`${CHANGELOG_URL}\` ✓" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} changelog URL warning(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Changelog URL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Duplicate file references check
continue-on-error: true
run: |
echo "### Duplicate File References" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Extract all <filename> and <folder> references
ALL_REFS=$(grep -oP '<(filename|folder)[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | sort || true)
if [ -z "$ALL_REFS" ]; then
echo "No file/folder references found — skipping." >> $GITHUB_STEP_SUMMARY
else
DUPES=$(echo "$ALL_REFS" | uniq -d)
if [ -n "$DUPES" ]; then
while IFS= read -r DUP; do
COUNT=$(echo "$ALL_REFS" | grep -cx "$DUP")
echo "::warning file=${MANIFEST}::Duplicate reference: \`${DUP}\` appears ${COUNT} times (may be valid if in different sections)"
echo "- **Duplicate:** \`${DUP}\` (${COUNT}x) — check if cross-section" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + 1))
done <<< "$DUPES"
else
TOTAL=$(echo "$ALL_REFS" | wc -l)
echo "All ${TOTAL} file/folder references are unique." >> $GITHUB_STEP_SUMMARY
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} duplicate reference(s) found.** Review for cross-section validity." >> $GITHUB_STEP_SUMMARY
else
echo "**Duplicate file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Empty language keys check
continue-on-error: true
run: |
echo "### Empty Language Keys" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$LANG_FILES" ]; then
echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL_FILES=0
for FILE in $LANG_FILES; do
TOTAL_FILES=$((TOTAL_FILES + 1))
# Find lines with KEY= but no value (empty or whitespace-only after =)
EMPTY_KEYS=$(grep -nP '^[A-Z_]+=\s*$' "$FILE" 2>/dev/null || true)
if [ -n "$EMPTY_KEYS" ]; then
COUNT=$(echo "$EMPTY_KEYS" | wc -l)
echo "::warning file=${FILE}::${COUNT} empty language key(s)"
echo "- \`${FILE}\`: ${COUNT} empty key(s)" >> $GITHUB_STEP_SUMMARY
while IFS= read -r LINE; do
LINE_NUM=$(echo "$LINE" | cut -d: -f1)
KEY=$(echo "$LINE" | cut -d: -f2 | cut -d= -f1)
echo " - Line ${LINE_NUM}: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY
done <<< "$EMPTY_KEYS"
WARNINGS=$((WARNINGS + COUNT))
fi
done
if [ "$WARNINGS" -eq 0 ]; then
echo "All ${TOTAL_FILES} language file(s) have populated keys." >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} empty language key(s) across ${TOTAL_FILES} file(s).**" >> $GITHUB_STEP_SUMMARY
else
echo "**Empty language keys check passed.**" >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Validate release readiness
run: |
@@ -338,15 +1092,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -384,14 +1142,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP
run: php -v && composer --version
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -448,3 +1211,24 @@ jobs:
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
pre-release:
name: Build RC Pre-Release
runs-on: ubuntu-latest
needs: [lint-and-validate, test]
if: github.event_name == 'pull_request'
steps:
- name: Trigger pre-release build
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
run: |
curl -s -X POST \
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+11 -11
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -21,7 +21,7 @@ permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
@@ -37,13 +37,13 @@ jobs:
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
+2 -6
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
@@ -25,10 +25,6 @@
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.01.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:
MOKOGITEA_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.MOKOGITEA_TOKEN }}"
API="${MOKOGITEA_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="${MOKOGITEA_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
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
+38 -25
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# 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
@@ -96,6 +96,32 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
@@ -470,39 +496,26 @@ jobs:
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_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\"}}"
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_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."
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
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."
secrets: inherit
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
MOKOGITEA_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:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${MOKOGITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${MOKOGITEA_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
+18 -13
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# VERSION: 05.02.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
@@ -59,26 +59,31 @@ jobs:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
submodules: recursive
- name: Setup moko-platform tools
- name: Update submodules to main
run: |
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Detect platform
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.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
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -euo pipefail
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
fi
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
# 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
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
ENCODED=$(php -r 'echo rawurlencode(getenv("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"
+29 -40
View File
@@ -7,8 +7,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
@@ -33,7 +33,8 @@ on:
- scripts
- repo
pull_request:
push:
branches:
- main
permissions:
contents: read
@@ -76,7 +77,7 @@ jobs:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
@@ -670,42 +671,30 @@ jobs:
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
report-scripts:
name: "Report: Scripts Governance"
needs: [access_check, scripts_governance]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
needs.scripts_governance.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "Scripts Governance"
workflow: "Repo Health"
severity: error
details: "Scripts directory policy violations detected. Review required and allowed directories."
secrets: inherit
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
report-health:
name: "Report: Repository Health"
needs: [access_check, repo_health]
if: >-
always() &&
needs.repo_health.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "Repository Health"
workflow: "Repo Health"
severity: error
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
secrets: inherit
-98
View File
@@ -1,98 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+130
View File
@@ -0,0 +1,130 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogitea/workflows/version-set.yml
# VERSION: 01.00.00
# BRIEF: Set or reset the extension version across all version-bearing files
name: "Joomla: Set Version"
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 01.00.00)"
required: true
type: string
branch:
description: "Branch to update (default: current)"
required: false
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
exit 1
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Update manifest version
run: |
MANIFEST=""
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla extension manifest found — skipping manifest update"
else
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
fi
- name: Update README.md version
run: |
if [ -f "README.md" ]; then
if grep -qP '^\s*VERSION:\s*\d' README.md; then
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
echo "README.md version updated to ${VERSION}"
else
echo "::warning::No VERSION line found in README.md — skipping"
fi
fi
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Check if this version already has an entry
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
else
# Insert new version entry after [Unreleased] or at the top after header
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
else
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
fi
echo "CHANGELOG.md: added entry for ${VERSION}"
fi
else
echo "::warning::No CHANGELOG.md found — skipping"
fi
- name: Update FILE INFORMATION blocks
run: |
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
while IFS= read -r -d '' FILE; do
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
echo "Updated FILE INFORMATION VERSION in ${FILE}"
fi
done
- name: Commit and push
run: |
git config user.name "Moko Consulting [bot]"
git config user.email "hello@mokoconsulting.tech"
git add -A
if git diff --cached --quiet; then
echo "No version changes detected — nothing to commit"
else
git commit -m "chore: set version to ${VERSION} [skip bump]
Authored-by: Moko Consulting"
git push
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
fi
@@ -0,0 +1,88 @@
# 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:
workflow_dispatch:
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_name == 'workflow_dispatch' ||
(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: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install PHP
run: |
if ! command -v php &> /dev/null; then
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
fi
- 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}
+101 -8
View File
@@ -5,15 +5,108 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Removed
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
## [1.2.0] - Unreleased
### Added
- Multi-category support with parent/child hierarchy (#1)
- Categories admin CRUD — list, edit, color picker, custom marker icon
- Location-category junction table (many-to-many)
- Categories tab on location edit form (multi-select)
- Category filtering on site frontend (`catid` parameter)
- Custom map markers per category — SVG/PNG icon support (#2)
- Map module JOINs category data for marker icons and colors
- `access.xml` with full Joomla ACL permissions (#30)
- SQL update schema with `sql/updates/mysql/` versioned files (#31)
- REST API via Web Services plugin (`plg_webservices_mokosuitestorelocator`) (#29)
- API controller + JSON:API view for locations CRUD at `/api/v1/mokosuitestorelocator/locations`
- `LocationBridgeHelper` — static helper for cross-extension integration (#48)
- `LocationSavedEvent` — fires `onStoreLocatorLocationSaved` for cache invalidation
- Plugin added to package manifest
- Leaflet map on location detail page with marker and popup (#57)
- Leaflet.markercluster for automatic marker grouping at low zoom levels (#61)
- Clustering toggle parameter in map module settings (enabled by default)
- Junction table orphan cleanup on location/category delete (#60)
- License key warning on install/update when no download key is configured
- Download key (dlid) preserved across package upgrades
- Pretty display names for all extensions in Joomla admin (e.g. "MokoSuite Store Locator" instead of raw element names)
- Plugin `.sys.ini` language file for system-level name translation
### Changed
- Map module dispatcher uses aliased table queries with category JOIN
- ORDER BY clauses in admin and site models now validated against filter_fields allowlist
- Category filter uses EXISTS subquery instead of JOIN to avoid ONLY_FULL_GROUP_BY errors (#59)
- Inline `<script>` blocks replaced with `$wa->addInlineScript()` for CSP nonce support (#34)
### Removed
- Dead `catid` column from locations table — junction table is the source of truth (#58)
- `idx_catid` index dropped from locations table
### Security
- CSV import: MIME type validation, 2 MB file size limit, delimiter allowlist (#34)
- CSV import: formula injection prevention (strips leading `=+\-@\t\r` characters)
- ORDER BY injection prevention — replaced `$db->escape()` with allowlist validation
- Map module: `$mapHeight` CSS value validated with regex pattern
- CSP compatibility: all inline scripts use WebAssetManager for automatic nonce injection (#34)
- XSS fix: detail map popup uses DOM textContent instead of raw string in bindPopup()
### Fixed
- SQL migration compatibility: removed `DROP COLUMN IF EXISTS` (MySQL 8.0.13+ only) in favor of plain `DROP COLUMN`
## [1.1.0] - 2026-06-23
### Added
- Haversine proximity search — filter locations by distance from user's coordinates
- 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
- FocalPoint (Shack Locations) migration import
- Language strings for directions, geocoding feedback, and import UI
## [01.00.00] - 2026-06-23
### Added
- Admin `LocationController` (FormController) for single-record save/cancel/apply
- Admin `LocationsController` (AdminController) for bulk publish/unpublish/delete
- Admin location edit view and tabbed template (Details, Address, Contact)
- Admin locations list renders data rows with edit links and published toggle
- `LocationTable::check()` validation: required title, auto-alias, lat/lng range, timestamps
- `LocationsModel::populateState()` for filter persistence
- Search filter across title, city, state, address
- Published state filter and sort ordering support
- Filter form XML (`filter_locations.xml`) with search tools bar
- Language strings for filters, sort options, and save messages
- Site frontend `DisplayController` routing to list and detail views
- Site `LocationsModel` — published locations with search, city, and state filters
- Site `LocationModel` — single location by ID (published only)
- Site locations list view with Schema.org `LocalBusiness` markup and pagination
- Site location detail view with address, contact, hours, and map placeholder
- SEF URL router (`Service\Router`) with menu/standard/nomenu rules
- Menu item types: "All Locations" list and "Location Detail" with location picker
- Site language strings for frontend views and menu items
- Router registered in service provider and component extension class
- Map module dispatcher loads published locations with coordinates from DB
- Leaflet.js/OpenStreetMap integration with markers, popups, and auto-fit bounds
- Leaflet CSS/JS loaded via Joomla Web Asset Manager (`registerAndUseStyle`/`registerAndUseScript`)
- Search module dispatcher loads distinct cities/states and builds radius options
- City dropdown filter on search form (populated from DB, toggled by module param)
- Radius dropdown filter with configurable distance values and unit (miles/km)
- Geolocation "Use My Location" button with browser geolocation API
- Hidden lat/lng fields passed to component for proximity search
- Language strings for search module (city, radius, geolocation states)
### Removed
- Makefile (no longer used)
- deploy-manual.yml workflow
### Previous (scaffold)
- Initial package scaffold with component, map module, and search module
- Database schema for locations table with coordinates
- Admin MVC for location CRUD
- Frontend location listing view with Schema.org markup
- Map module with Leaflet/Google Maps provider support
- Search module with city and radius filter options
- Admin MVC skeleton for location CRUD
- Map module with Leaflet/Google Maps provider support (stub)
- Search module with city and radius filter options (stub)
+63
View File
@@ -0,0 +1,63 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
**MokoSuiteStoreLocator** -- A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
| Field | Value |
|---|---|
| **Platform** | joomla |
| **Extension type** | package (component + modules) |
| **Element** | `pkg_mokosuitestorelocator` |
| **Language** | PHP |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Wiki** | [MokoSuiteStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/wiki) |
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
## Package Contents
| Extension | Type | Element |
|---|---|---|
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
## Common Commands
```bash
composer install # Install PHP dev dependencies
```
## Architecture
This is a Joomla package. Key layout:
- `source/pkg_mokosuitestorelocator.xml` -- package manifest
- `source/script.php` -- package install/upgrade/uninstall script
- `source/packages/com_mokosuitestorelocator/` -- main component
- `admin/` -- admin MVC (controllers, models, views, forms, tables, SQL)
- `site/` -- frontend MVC (controllers, models, views, templates)
- `mokosuitestorelocator.xml` -- component manifest
- `source/packages/mod_mokosuitestorelocator_map/` -- map display module
- `source/packages/mod_mokosuitestorelocator_search/` -- search/filter module
## Database Table
`#__mokosuitestorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
## Rules
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev/`, merge to `main` for release
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
- **PHP minimum**: 8.2
- **Joomla minimum**: 5.0
- **Joomla table operations**: always use bind() -> check() -> store(), never save()
- **Namespace**: `Moko\Component\MokoSuiteStoreLocator` for the component
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO:
VERSION: 04.04.01
VERSION: 01.01.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+161 -128
View File
@@ -1,128 +1,161 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: {{DEFGROUP}}
INGROUP: Project.Documentation
REPO: https://github.com/mokoconsulting-tech/MokoSuiteTOS
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 **MokoSuiteTOS**!
This repository is governed by **[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)** — the authoritative source of coding standards, workflows, and policies for all Moko Consulting repositories.
## Branch Strategy
| Branch | Purpose | Deploys To |
|--------|---------|------------|
| `main` | Bleeding edge — all development merges here | CI only |
| `dev/XX.YY.ZZ` | Feature development | Dev server (version: "development") |
| `version/XX.YY` | Stable frozen snapshot | Demo + RS servers |
### Development Workflow
```
1. Create branch: git checkout -b dev/XX.YY.ZZ/my-feature
2. Develop + test (dev server auto-deploys on push)
3. Open PR → main (squash merge only)
4. Auto-release (version branch + tag + GitHub Release created automatically)
```
### Branch Naming
| Prefix | Use |
|--------|-----|
| `dev/XX.YY.ZZ` | Feature development (e.g., `dev/02.00.00/add-extrafields`) |
| `version/XX.YY` | Stable release (auto-created, never manually pushed) |
| `chore/` | Automated sync branches (managed by MokoStandards) |
> **Never use** `feature/`, `hotfix/`, or `release/` prefixes — they are not part of the MokoStandards branch strategy.
## Commit Conventions
Use [conventional commits](https://www.conventionalcommits.org/):
```
feat(scope): add new extrafield for invoice tracking
fix(sql): correct column type in llx_mytable
docs(readme): update installation instructions
chore(deps): bump enterprise library to 04.02.30
```
**Valid types:** `feat` | `fix` | `docs` | `chore` | `ci` | `refactor` | `style` | `test` | `perf` | `revert` | `build`
## Pull Request Workflow
1. **Branch** from `main` using `dev/XX.YY.ZZ/description` format
2. **Bump** the patch version in `README.md` before opening the PR
3. **Title** must be a valid conventional commit subject line
4. **Target** `main` — squash merge only (merge commits are disabled)
5. **CI checks** must pass before merge
### What Happens on Merge
When your PR is merged to `main`, these workflows run automatically:
1. **sync-version-on-merge** — auto-bumps patch version, propagates to all file headers
2. **auto-release** — creates `version/XX.YY` branch, git tag, and GitHub Release
3. **deploy-demo / deploy-rs** — deploys to demo and RS servers (if `src/**` changed)
## Coding Standards
All contributions must follow [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards):
| Standard | Reference |
|----------|-----------|
| Coding Style | [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) |
| File Headers | [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) |
| Branching | [branch-release-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branch-release-strategy.md) |
| Merge Strategy | [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) |
| Scripting | [scripting-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/scripting-standards.md) |
| Build & Release | [build-release.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/build-release.md) |
## PR Checklist
- [ ] Branch named `dev/XX.YY.ZZ/description`
- [ ] Patch version bumped in `README.md`
- [ ] Conventional commit format for PR title
- [ ] All new files have FILE INFORMATION headers
- [ ] `declare(strict_types=1)` in all PHP files
- [ ] PHPDoc on all public methods
- [ ] Tests pass
- [ ] CHANGELOG.md updated
- [ ] No secrets, tokens, or credentials committed
## Custom Workflows
Place repo-specific workflows in `.github/workflows/custom/` — they are **never overwritten or deleted** by MokoStandards sync:
```
.github/workflows/
├── deploy-dev.yml ← Synced from MokoStandards
├── auto-release.yml ← Synced from MokoStandards
└── custom/ ← Your custom workflows (safe)
└── my-custom-ci.yml
```
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0-or-later](LICENSE) license.
---
*This file is synced from [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Do not edit directly — changes will be overwritten on the next sync.*
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
+119
View File
@@ -0,0 +1,119 @@
<!--
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: mokoconsulting-tech.Template-Joomla
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
VERSION: 01.01.01
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
-->
[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.00.04-blue)](https://github.com/mokoconsulting-tech/MokoStandards)
# Project Governance
## Overview
This document defines the governance model for the `Template-Joomla` repository within the
`mokoconsulting-tech` organization. It is automatically maintained by
[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) v04.00.04.
Full governance policy is defined in the MokoStandards source repository:
[docs/policy/GOVERNANCE.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md)
---
## Roles and Responsibilities
### Maintainer
**GitHub**: @mokoconsulting-tech
**Authority**: Final decision-making authority on all matters for this repository.
**Responsibilities**:
- Review and merge pull requests
- Maintain code quality and standards compliance
- Manage releases and versioning
- Respond to issues and security reports
### Contributors
**Authority**: Submit changes via pull requests.
**Requirements**:
- Read and accept `CODE_OF_CONDUCT.md`
- Follow `CONTRIBUTING.md` guidelines
---
## Decision-Making
All changes must be submitted as pull requests. The maintainer (@mokoconsulting-tech)
reviews and approves all changes before they are merged.
### Sole Operator Policy
This organization operates under a **sole operator** model. The maintainer (@mokoconsulting-tech)
is the sole employee and owner and may self-approve pull requests when no second reviewer is
available. The following requirements remain mandatory regardless:
1. **Pull Requests Required** — all changes to protected branches go through a PR.
2. **Automated Checks** — all CI checks must pass before merging.
3. **Audit Trail** — issues, pull requests, and commit history are preserved.
4. **Documentation** — changes are documented in `CHANGELOG.md`.
See the full policy:
[Sole Operator Policy](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md#sole-operator-policy)
---
## Change Management
| Change Type | Approval | Process |
|-------------|----------|---------|
| Routine (docs, bug fixes) | Maintainer | PR → CI pass → merge |
| Significant (new features) | Maintainer | PR with description → CI pass → merge |
| Major (breaking, architecture) | Maintainer | Issue discussion → PR → CI pass → merge |
| Emergency (security) | Maintainer | Labelled `EMERGENCY` → immediate merge → post-mortem |
---
## Reporting Issues
- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/Template-Joomla/issues)
- **Security vulnerabilities**: See [SECURITY.md](./SECURITY.md)
- **Code of Conduct**: See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
- **Contact**: dev@mokoconsulting.tech
---
## Metadata
| Field | Value |
| ------------- | ----------------------------------------------- |
| Document Type | Policy |
| Domain | Governance |
| Applies To | mokoconsulting-tech/Template-Joomla |
| Jurisdiction | Tennessee, USA |
| Maintainer | @mokoconsulting-tech |
| Standards | MokoStandards v04.00.04 |
| Repo | https://github.com/mokoconsulting-tech/Template-Joomla |
| Path | /GOVERNANCE.md |
| Status | Active — auto-maintained by MokoStandards |
-135
View File
@@ -1,135 +0,0 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This is a reference Makefile for building Joomla extensions.
# Copy this to your repository root as "Makefile" and customize as needed.
#
# Supports: Modules, Plugins, Components, Packages, Templates
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokosuitestorelocator
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_mokosuitestorelocator.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
+48 -14
View File
@@ -1,20 +1,21 @@
# MokoSuiteStoreLocator
A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
## Package Contents
| Extension | Description |
|---|---|
| `com_mokosuitestorelocator` | Component for managing store locations (admin CRUD + frontend listing) |
| `mod_mokosuitestorelocator_map` | Site module displaying an interactive map with location markers |
| `mod_mokosuitestorelocator_search` | Site module providing search/filter form for finding locations |
| Extension | Type | Element |
|---|---|---|
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Web Services API | plugin (webservices) | `plg_webservices_mokosuitestorelocator` |
## Requirements
- Joomla 4.4+ or 5.x
- PHP 8.1+
- MySQL 5.7+ / MariaDB 10.3+
- Joomla 5.x or 6.x
- PHP 8.2+
- MySQL 8.0+ / MariaDB 10.4+
## Installation
@@ -24,11 +25,44 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
## Features
- Manage store locations with address, coordinates, contact info, and business hours
- Interactive map display (OpenStreetMap/Leaflet or Google Maps)
- Location search by city, postcode, or radius
- Schema.org LocalBusiness structured data markup
- Category support for grouping locations
### Implemented
- **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image)
- **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete
- **Multi-category** — categories with parent/child hierarchy, color, custom marker icons, many-to-many assignments
- **Custom map markers** — per-category SVG/PNG marker icons on the Leaflet map
- **Site frontend** — locations list and detail views with pagination and category filtering
- **Schema.org** — LocalBusiness structured data markup on all frontend templates
- **SEF URLs** — router with menu, standard, and nomenu rules
- **Menu items** — "All Locations" list and single "Location Detail" picker
- **Interactive map** — Leaflet.js with OpenStreetMap tiles, markers with popups, auto-fit bounds, marker clustering
- **Location search** — city dropdown, radius filter, and browser geolocation ("Use My Location")
- **Proximity search** — Haversine distance filtering with distance-sorted results
- **Get Directions** — Google Maps directions link on detail page and map popups
- **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM)
- **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping
- **FocalPoint migration** — one-click import from Shack Locations / FocalPoint
- **REST API** — JSON:API endpoints via Joomla Web Services plugin
- **ACL permissions** — `access.xml` with standard Joomla permission actions
- **SQL update schema** — versioned migration files for safe upgrades
- **Shop integration** — `LocationBridgeHelper` for cross-extension data access, `LocationSavedEvent` for cache invalidation
- **Detail page map** — Leaflet map on single location view with marker and popup
- **Marker clustering** — Leaflet.markercluster groups nearby markers at low zoom levels (toggleable)
- **Security hardening** — CSV injection prevention, MIME validation, ORDER BY allowlists, CSP-compatible inline scripts
- **Junction cleanup** — orphan rows automatically removed when locations or categories are deleted
### Planned
- Google Maps provider as alternative to Leaflet
- CSV export
- Photo gallery per location
- Address autocomplete on admin edit form
## Development
```bash
composer install # Install PHP dev dependencies
```
Source code lives in `source/packages/` — one directory per sub-extension.
## License
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 04.04.01
VERSION: 01.01.01
BRIEF: Security vulnerability reporting and handling policy
-->
-237
View File
@@ -1,237 +0,0 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+25 -7
View File
@@ -1,9 +1,27 @@
{
"name": "mokoconsulting/mokosuitestorelocator",
"description": "Joomla store locator listing package with component and modules",
"type": "joomla-package",
"license": "GPL-3.0-or-later",
"require": {
"php": ">=8.1"
}
"name": "mokoconsulting/mokojoomgallery",
"description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display",
"type": "joomla-package",
"version": "01.00.00",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Moko Consulting",
"email": "hello@mokoconsulting.tech",
"homepage": "https://mokoconsulting.tech"
}
],
"require": {
"php": ">=8.1"
},
"require-dev": {
"squizlabs/php_codesniffer": "^3.7",
"phpstan/phpstan": "^1.10",
"joomla/coding-standards": "3.0.x-dev"
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"sort-packages": true
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitestorelocator">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
</section>
</access>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Category edit form -->
<form>
<fieldset name="details">
<field
name="id"
type="hidden"
/>
<field
name="title"
type="text"
label="JGLOBAL_TITLE"
required="true"
size="40"
/>
<field
name="alias"
type="text"
label="JFIELD_ALIAS_LABEL"
size="40"
hint="JFIELD_ALIAS_PLACEHOLDER"
/>
<field
name="parent_id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_PARENT"
default="0"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
>
<option value="0">COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT</option>
</field>
<field
name="description"
type="editor"
label="JGLOBAL_DESCRIPTION"
filter="safehtml"
buttons="true"
/>
<field
name="published"
type="list"
label="JSTATUS"
default="1"
>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
</fieldset>
<fieldset name="appearance" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE">
<field
name="color"
type="color"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR"
default=""
/>
<field
name="marker_icon"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON"
/>
</fieldset>
</form>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field
name="search"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL"
hint="JSEARCH_FILTER"
/>
<field
name="published"
type="list"
label="JOPTION_SELECT_PUBLISHED"
onchange="this.form.submit();"
>
<option value="">JOPTION_SELECT_PUBLISHED</option>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
</fields>
<fields name="list">
<field
name="fullordering"
type="list"
label="JGLOBAL_SORT_BY"
default="a.ordering ASC"
onchange="this.form.submit();"
>
<option value="a.title ASC">JGLOBAL_TITLE_ASC</option>
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
<option value="a.ordering ASC">JGRID_HEADING_ORDERING_ASC</option>
<option value="a.ordering DESC">JGRID_HEADING_ORDERING_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
</field>
<field
name="limit"
type="limitbox"
label="JGLOBAL_LIST_LIMIT"
default="25"
onchange="this.form.submit();"
/>
</fields>
</form>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field
name="search"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL"
hint="JSEARCH_FILTER"
inputmode="search"
/>
<field
name="published"
type="status"
label="JOPTION_SELECT_PUBLISHED"
onchange="this.form.submit();"
>
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
</fields>
<fields name="list">
<field
name="fullordering"
type="list"
label="JGLOBAL_SORT_BY"
default="a.title ASC"
onchange="this.form.submit();"
>
<option value="a.title ASC">JGLOBAL_TITLE_ASC</option>
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
<option value="a.city ASC">COM_MOKOJOOMSTORELOCATOR_CITY_ASC</option>
<option value="a.city DESC">COM_MOKOJOOMSTORELOCATOR_CITY_DESC</option>
<option value="a.published ASC">JSTATUS_ASC</option>
<option value="a.published DESC">JSTATUS_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
</field>
<field
name="limit"
type="limitbox"
label="JGLOBAL_LIST_LIMIT"
default="25"
onchange="this.form.submit();"
/>
</fields>
</form>
@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Location edit form -->
<form>
<fieldset name="details" addfieldprefix="Moko\Component\MokoSuiteStoreLocator\Administrator\Field">
<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="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>
<option value="-2">JTRASHED</option>
</field>
</fieldset>
<fieldset name="categories" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES">
<field
name="categories"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORIES"
multiple="true"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
/>
</fieldset>
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
<field
name="address"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS"
size="60"
/>
<field
name="city"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CITY"
size="40"
/>
<field
name="state"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_STATE"
size="40"
/>
<field
name="postcode"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE"
size="20"
/>
<field
name="country"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY"
size="40"
/>
</fieldset>
<fieldset name="coordinates" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES">
<field
name="latitude"
type="number"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE"
step="0.00000001"
min="-90"
max="90"
/>
<field
name="longitude"
type="number"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE"
step="0.00000001"
min="-180"
max="180"
/>
</fieldset>
<fieldset name="contact" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT">
<field
name="phone"
type="tel"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE"
size="30"
/>
<field
name="email"
type="email"
label="JGLOBAL_EMAIL"
size="40"
/>
<field
name="website"
type="url"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE"
size="60"
/>
<field
name="hours"
type="textarea"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS"
rows="5"
/>
</fieldset>
<fieldset name="image" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE">
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
/>
</fieldset>
</form>
@@ -0,0 +1,81 @@
; MokoSuiteStoreLocator - Admin language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
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."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE="Import from FocalPoint"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC="Migrate locations from an installed FocalPoint (Shack Locations) component. Coordinates, custom fields (email, website, hours), and metadata are mapped automatically."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON="Import FocalPoint Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS="%d location(s) imported from FocalPoint."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE="The uploaded file exceeds the 2 MB size limit."
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_PARENT="Parent Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT="- No Parent -"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR="Color"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON="Custom Marker Icon"
COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION="Store Location Categories"
COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED="A category title is required."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance"
@@ -0,0 +1,8 @@
; MokoSuiteStoreLocator - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
@@ -0,0 +1,57 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
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;
/**
* The store locator service provider.
*
* @since 1.0.0
*/
return new class implements ServiceProviderInterface
{
/**
* Registers the service provider with a DI container.
*
* @param Container $container The DI container.
*
* @return void
*
* @since 1.0.0
*/
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->set(
ComponentInterface::class,
function (Container $container) {
$component = new MokoSuiteStoreLocatorComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
return $component;
}
);
}
};
@@ -0,0 +1,73 @@
-- =========================================================================
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- SPDX-License-Identifier: GPL-3.0-or-later
--
-- MokoSuiteStoreLocator - Store locations table
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`description` text NOT NULL,
`address` varchar(255) NOT NULL DEFAULT '',
`city` varchar(100) NOT NULL DEFAULT '',
`state` varchar(100) NOT NULL DEFAULT '',
`postcode` varchar(20) NOT NULL DEFAULT '',
`country` varchar(100) NOT NULL DEFAULT '',
`latitude` decimal(10, 8) DEFAULT NULL,
`longitude` decimal(11, 8) DEFAULT NULL,
`phone` varchar(50) NOT NULL DEFAULT '',
`email` varchar(255) NOT NULL DEFAULT '',
`website` varchar(255) NOT NULL DEFAULT '',
`hours` text NOT NULL,
`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 '0000-00-00 00:00:00',
`created_by` int(10) unsigned NOT NULL DEFAULT 0,
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified_by` int(10) unsigned NOT NULL DEFAULT 0,
`checked_out` int(10) unsigned DEFAULT NULL,
`checked_out_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`(191)),
KEY `idx_coordinates` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Categories table
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`description` text NOT NULL,
`color` varchar(7) NOT NULL DEFAULT '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Location-Category junction table (many-to-many)
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,8 @@
-- =========================================================================
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- SPDX-License-Identifier: GPL-3.0-or-later
-- =========================================================================
DROP TABLE IF EXISTS `#__mokosuitestorelocator_location_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`;
@@ -0,0 +1 @@
-- MokoSuiteStoreLocator 01.00.00 — Initial release, no schema changes needed.
@@ -0,0 +1,28 @@
-- MokoSuiteStoreLocator 01.00.01 — Add categories and location-category junction tables.
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`description` text NOT NULL,
`color` varchar(7) NOT NULL DEFAULT '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,5 @@
-- MokoSuiteStoreLocator 01.00.02
-- Legacy catid column removed from install.mysql.sql.
-- No runtime migration needed — Joomla aborts on DROP errors
-- and fresh installs never had the column.
SELECT 1;
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
/**
* Categories list controller.
*
* @since 1.2.0
*/
class CategoriesController extends AdminController
{
/**
* Get the model for this controller.
*
* @param string $name Model name.
* @param string $prefix Model prefix.
* @param array $config Configuration.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
* @since 1.2.0
*/
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,22 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Category edit controller.
*
* @since 1.2.0
*/
class CategoryController extends FormController
{
}
@@ -0,0 +1,29 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Default controller for the admin side of the component.
*
* @since 1.0.0
*/
class DisplayController extends BaseController
{
/**
* The default view.
*
* @var string
* @since 1.0.0
*/
protected $default_view = 'locations';
}
@@ -0,0 +1,156 @@
<?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', ',');
// Validate delimiter against allowlist
if (!\in_array($delimiter, [',', ';', '|', "\t"], true))
{
$delimiter = ',';
}
$csvFile = $file['csv_file'] ?? null;
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
// Enforce 2 MB file size limit
if ($csvFile['size'] > 2 * 1024 * 1024)
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
// Validate file extension
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
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;
}
// Validate MIME type
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($csvFile['tmp_name']);
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel', 'application/octet-stream'];
if (!$mime || !\in_array($mime, $allowedMimes, true))
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
$result = $model->processImport($csvFile['tmp_name'], $delimiter);
if ($result['imported'] > 0)
{
$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));
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* @return void
*
* @since 1.1.0
*/
public function focalpoint(): void
{
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokosuitestorelocator'))
{
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
return;
}
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Model\ImportModel $model */
$model = $this->getModel('Import', 'Administrator');
$result = $model->importFromFocalPoint();
if ($result['imported'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS', $result['imported']));
}
if ($result['skipped'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning');
}
foreach ($result['errors'] as $error)
{
$this->setMessage($error, 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
}
}
@@ -0,0 +1,45 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Controller for a single location form.
*
* @since 1.0.0
*/
class LocationController extends FormController
{
/**
* The prefix to use with controller messages.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
/**
* The view list to redirect to after save.
*
* @var string
* @since 1.0.0
*/
protected $view_list = 'locations';
/**
* The view item for edit.
*
* @var string
* @since 1.0.0
*/
protected $view_item = 'location';
}
@@ -0,0 +1,45 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
/**
* Locations list controller — handles bulk publish/unpublish/delete.
*
* @since 1.0.0
*/
class LocationsController extends AdminController
{
/**
* The prefix to use with controller messages.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATIONS';
/**
* Proxy for getModel.
*
* @param string $name The model name.
* @param string $prefix The model prefix.
* @param array $config Configuration array for model.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
* @since 1.0.0
*/
public function getModel($name = 'Location', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,46 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Event;
defined('_JEXEC') or die;
use Joomla\CMS\Event\AbstractEvent;
/**
* Event fired after a location is saved, for cross-extension integration.
*
* @since 1.2.0
*/
class LocationSavedEvent extends AbstractEvent
{
/**
* Constructor.
*
* @param string $name The event name.
* @param array $arguments Event arguments: ['locationData' => array].
*
* @since 1.2.0
*/
public function __construct(string $name, array $arguments = [])
{
parent::__construct($name, $arguments);
}
/**
* Get the saved location data.
*
* @return array
*
* @since 1.2.0
*/
public function getLocationData(): array
{
return $this->getArgument('locationData', []);
}
}
@@ -0,0 +1,25 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Extension\MVCComponent;
/**
* Component class for com_mokosuitestorelocator.
*
* @since 1.0.0
*/
class MokoSuiteStoreLocatorComponent extends MVCComponent implements RouterServiceInterface
{
use RouterServiceTrait;
}
@@ -0,0 +1,162 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\ParameterType;
/**
* Bridge helper for external extensions (e.g. MokoSuiteShop) to query location data.
*
* All methods are static and return plain objects/arrays with no Joomla model dependencies.
*
* @since 1.2.0
*/
class LocationBridgeHelper
{
/**
* Get all active locations.
*
* @param bool $publishedOnly Only return published locations.
*
* @return array
*
* @since 1.2.0
*/
public static function getLocations(bool $publishedOnly = true): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'));
if ($publishedOnly)
{
$query->where($db->quoteName('published') . ' = 1');
}
$query->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single location by ID.
*
* @param int $locationId The location ID.
*
* @return object|null
*
* @since 1.2.0
*/
public static function getById(int $locationId): ?object
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $locationId, ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadObject() ?: null;
}
/**
* Get locations within a radius using Haversine formula.
*
* @param float $lat Latitude of the search origin.
* @param float $lng Longitude of the search origin.
* @param float $radiusMiles Search radius in miles.
* @param int $limit Maximum results.
*
* @return array Objects with an additional `distance` property (miles).
*
* @since 1.2.0
*/
public static function getNearby(float $lat, float $lng, float $radiusMiles = 25, int $limit = 10): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$haversine = '(3959 * ACOS(LEAST(1, GREATEST(-1, '
. 'SIN(RADIANS(' . $db->quoteName('latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) '
. '+ COS(RADIANS(' . $db->quoteName('latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) '
. '* COS(RADIANS(' . $db->quoteName('longitude') . ' - ' . (float) $lng . '))'
. '))))';
$query->select('*')
->select($haversine . ' AS distance')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('longitude') . ' IS NOT NULL')
->where($haversine . ' <= ' . (float) $radiusMiles)
->order('distance ASC');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by city.
*
* @param string $city City name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByCity(string $city): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('city') . ' = :city')
->bind(':city', $city)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by state/province.
*
* @param string $state State/province name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByState(string $state): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('state') . ' = :state')
->bind(':state', $state)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,126 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Categories list model.
*
* @since 1.2.0
*/
class CategoriesModel extends ListModel
{
/**
* Constructor.
*
* @param array $config Configuration settings.
*
* @since 1.2.0
*/
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'published', 'a.published',
'ordering', 'a.ordering',
];
}
parent::__construct($config);
}
/**
* Populate the model state.
*
* @param string $ordering Default ordering column.
* @param string $direction Default ordering direction.
*
* @return void
*
* @since 1.2.0
*/
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
{
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
$this->setState('filter.search', $search);
$published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string');
$this->setState('filter.published', $published);
parent::populateState($ordering, $direction);
}
/**
* Build an SQL query to load the list data.
*
* @return QueryInterface
*
* @since 1.2.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_categories', 'a'));
// Count locations per category
$subQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc'))
->where($db->quoteName('lc.category_id') . ' = ' . $db->quoteName('a.id'));
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('location_count'));
// Filter by published state
$published = $this->getState('filter.published');
if (is_numeric($published))
{
$query->where($db->quoteName('a.published') . ' = :published')
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
}
elseif ($published === '')
{
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
}
// Search filter
$search = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')
->bind(':search', $search);
}
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
}
@@ -0,0 +1,85 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Form\Form;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
/**
* Single category edit model.
*
* @since 1.2.0
*/
class CategoryModel extends AdminModel
{
/**
* The type alias for this content type.
*
* @var string
* @since 1.2.0
*/
public $typeAlias = 'com_mokosuitestorelocator.category';
/**
* Get the form for this model.
*
* @param array $data Data for the form.
* @param boolean $loadData True if the form is to load its own data.
*
* @return Form|boolean
*
* @since 1.2.0
*/
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokosuitestorelocator.category',
'category',
['control' => 'jform', 'load_data' => $loadData]
);
if (empty($form))
{
return false;
}
return $form;
}
/**
* Load the data for the form.
*
* @return mixed
*
* @since 1.2.0
*/
protected function loadFormData()
{
return $this->getItem();
}
/**
* Get the table for this model.
*
* @param string $name The table name.
* @param string $prefix The table prefix.
* @param array $options Configuration array.
*
* @return Table
*
* @since 1.2.0
*/
public function getTable($name = 'Category', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
}
@@ -0,0 +1,397 @@
<?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)
{
$value = trim($row[$csvIndex] ?? '');
$data[$dbField] = $this->sanitizeCsvValue($value);
}
return $data;
}
/**
* Sanitize a CSV value to prevent formula injection.
*
* Strips leading characters that spreadsheet applications interpret as formulas.
*
* @param string $value Raw CSV cell value.
*
* @return string Sanitized value.
*
* @since 1.1.0
*/
private function sanitizeCsvValue(string $value): string
{
if ($value === '')
{
return $value;
}
// Strip leading formula trigger characters
return ltrim($value, "=+\-@\t\r");
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* Reads directly from #__focalpoint_locations and #__focalpoint_locationtypes
* tables and inserts into #__mokosuitestorelocator_locations using the standard
* bind()->check()->store() flow.
*
* @return array ['imported' => int, 'skipped' => int, 'errors' => string[]]
*
* @since 1.1.0
*/
public function importFromFocalPoint(): array
{
$result = ['imported' => 0, 'skipped' => 0, 'errors' => []];
$db = $this->getDatabase();
// Check if FocalPoint tables exist
$tables = $db->getTableList();
$prefix = $db->getPrefix();
$fpTable = $prefix . 'focalpoint_locations';
if (!\in_array($fpTable, $tables))
{
$result['errors'][] = 'FocalPoint is not installed — table #__focalpoint_locations not found.';
return $result;
}
// Load all FocalPoint locations
$query = $db->getQuery(true)
->select('a.*')
->from($db->quoteName('#__focalpoint_locations', 'a'))
->order($db->quoteName('a.id') . ' ASC');
$db->setQuery($query);
$fpLocations = $db->loadObjectList();
if (empty($fpLocations))
{
$result['errors'][] = 'No locations found in FocalPoint.';
return $result;
}
// Load location type names for category context
$typeQuery = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__focalpoint_locationtypes'));
$db->setQuery($typeQuery);
$typeNames = $db->loadObjectList('id');
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Table\LocationTable $table */
$table = $this->getMVCFactory()->createTable('Location', 'Administrator');
foreach ($fpLocations as $fpLoc)
{
$table->reset();
$table->id = 0;
// Parse custom fields JSON for email, website, phone, hours
$customData = $this->parseFocalPointCustomFields($fpLoc->customfieldsdata ?? '');
// Map FocalPoint fields to our schema
$data = [
'title' => $fpLoc->title,
'alias' => $fpLoc->alias ?: '',
'description' => trim(($fpLoc->description ?? '') . "\n" . ($fpLoc->fulldescription ?? '')),
'address' => $fpLoc->address ?? '',
'city' => $customData['city'] ?? '',
'state' => $customData['state'] ?? '',
'postcode' => $customData['postcode'] ?? $customData['zip'] ?? '',
'country' => $customData['country'] ?? '',
'latitude' => $fpLoc->latitude != 0 ? $fpLoc->latitude : null,
'longitude' => $fpLoc->longitude != 0 ? $fpLoc->longitude : null,
'phone' => $customData['phone'] ?? $fpLoc->phone ?? '',
'email' => $customData['email'] ?? '',
'website' => $customData['website'] ?? $customData['url'] ?? '',
'hours' => $customData['hours'] ?? $customData['business_hours'] ?? '',
'image' => $fpLoc->image ?? '',
'published' => (int) ($fpLoc->state ?? 0),
'ordering' => (int) ($fpLoc->ordering ?? 0),
'params' => '{}',
];
if (!$table->bind($data))
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->check())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->store())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
$result['imported']++;
}
return $result;
}
/**
* Parse FocalPoint customfieldsdata JSON into a flat key-value array.
*
* FocalPoint stores custom field data as JSON. The structure varies by version:
* - Simple: {"fieldname": "value", ...}
* - Nested: {"fieldname": {"value": "...", "label": "..."}, ...}
*
* We normalize to lowercase keys with string values for easy field matching.
*
* @param string $json The customfieldsdata JSON string.
*
* @return array Flat associative array of field_name => value.
*
* @since 1.1.0
*/
private function parseFocalPointCustomFields(string $json): array
{
if (empty($json) || $json === '{}')
{
return [];
}
$decoded = json_decode($json, true);
if (!\is_array($decoded))
{
return [];
}
$fields = [];
foreach ($decoded as $key => $value)
{
$normalizedKey = strtolower(trim(str_replace([' ', '-'], '_', $key)));
if (\is_array($value))
{
// Nested format: {"value": "...", "label": "..."}
$fields[$normalizedKey] = trim((string) ($value['value'] ?? $value[0] ?? ''));
}
else
{
$fields[$normalizedKey] = trim((string) $value);
}
}
return $fields;
}
}
@@ -0,0 +1,262 @@
<?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 Moko\Component\MokoSuiteStoreLocator\Administrator\Event\LocationSavedEvent;
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;
}
/**
* 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'
);
}
}
// Extract categories before parent::save (it won't know about junction table)
$categories = $data['categories'] ?? [];
unset($data['categories']);
if (!parent::save($data))
{
return false;
}
// Save category associations
$locationId = (int) $this->getState($this->getName() . '.id');
$this->saveCategories($locationId, $categories);
// Fire event for cross-extension integration (e.g. MokoSuiteShop)
$data['id'] = $locationId;
Factory::getApplication()->getDispatcher()->dispatch(
'onStoreLocatorLocationSaved',
new LocationSavedEvent('onStoreLocatorLocationSaved', ['locationData' => $data])
);
return true;
}
/**
* Save location-category associations in the junction table.
*
* @param int $locationId The location ID.
* @param array $categories Array of category IDs.
*
* @return void
*
* @since 1.2.0
*/
private function saveCategories(int $locationId, array $categories): void
{
$db = $this->getDatabase();
// Remove existing associations
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :locationId')
->bind(':locationId', $locationId, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
// Insert new associations
if (!empty($categories))
{
$query = $db->getQuery(true)
->insert($db->quoteName('#__mokosuitestorelocator_location_categories'))
->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]);
foreach ($categories as $catId)
{
$catId = (int) $catId;
if ($catId > 0)
{
$query->values($locationId . ', ' . $catId);
}
}
$db->setQuery($query);
$db->execute();
}
}
/**
* Load the data for the form, including category associations.
*
* @return mixed
*
* @since 1.0.0
*/
protected function loadFormData()
{
$data = $this->getItem();
if ($data && (int) $data->id > 0)
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('category_id'))
->from($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :id')
->bind(':id', $data->id, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$data->categories = $db->loadColumn();
}
return $data;
}
/**
* Geocode an address using the Nominatim (OpenStreetMap) API.
*
* @param array $data Location data with address fields.
*
* @return array|null ['lat' => float, 'lng' => float] or null on failure.
*
* @since 1.1.0
*/
private function geocodeAddress(array $data): ?array
{
$parts = array_filter([
$data['address'] ?? '',
$data['city'] ?? '',
$data['state'] ?? '',
$data['postcode'] ?? '',
$data['country'] ?? '',
]);
if (empty($parts))
{
return null;
}
$query = implode(', ', $parts);
try
{
$http = HttpFactory::getHttp();
$url = 'https://nominatim.openstreetmap.org/search?'
. http_build_query(['format' => 'json', 'limit' => 1, 'q' => $query]);
$response = $http->get($url, ['User-Agent' => 'MokoSuiteStoreLocator/1.1 (+https://mokoconsulting.tech)'], 10);
if ($response->code !== 200)
{
return null;
}
$results = json_decode($response->body, true);
if (isset($results[0]['lat']) && is_numeric($results[0]['lat'])
&& isset($results[0]['lon']) && is_numeric($results[0]['lon']))
{
return [
'lat' => round((float) $results[0]['lat'], 8),
'lng' => round((float) $results[0]['lon'], 8),
];
}
}
catch (\Exception $e)
{
Factory::getApplication()->enqueueMessage(
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED', $e->getMessage()),
'warning'
);
}
return null;
}
}
@@ -0,0 +1,128 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Locations list model for the admin view.
*
* @since 1.0.0
*/
class LocationsModel extends ListModel
{
/**
* Constructor.
*
* @param array $config An optional associative array of configuration settings.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'city', 'a.city',
'state', 'a.state',
'published', 'a.published',
'ordering', 'a.ordering',
];
}
parent::__construct($config);
}
/**
* Populate the model state.
*
* @param string $ordering Default ordering column.
* @param string $direction Default ordering direction.
*
* @return void
*
* @since 1.0.0
*/
protected function populateState($ordering = 'a.title', $direction = 'ASC')
{
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
$this->setState('filter.search', $search);
$published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string');
$this->setState('filter.published', $published);
parent::populateState($ordering, $direction);
}
/**
* Build an SQL query to load the list data.
*
* @return QueryInterface
*
* @since 1.0.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'));
// Filter by published state
$published = $this->getState('filter.published');
if (is_numeric($published))
{
$query->where($db->quoteName('a.published') . ' = :published')
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
}
elseif ($published === '')
{
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
}
// Search filter
$search = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where(
'(' . $db->quoteName('a.title') . ' LIKE :search'
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
. ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
)
->bind(':search', $search)
->bind(':search2', $search)
->bind(':search3', $search)
->bind(':search4', $search);
}
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.title';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
}
@@ -0,0 +1,112 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
/**
* Category table class.
*
* @since 1.2.0
*/
class CategoryTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver object.
*
* @since 1.2.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokosuitestorelocator_categories', 'id', $db);
}
/**
* Overloaded check method to ensure data integrity.
*
* @return boolean True if the data is valid.
*
* @since 1.2.0
*/
public function check(): bool
{
if (trim($this->title) === '')
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED'));
return false;
}
if (trim($this->alias) === '')
{
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
// Validate color format
if ($this->color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color))
{
$this->color = '';
}
$now = Factory::getDate()->toSql();
if (!(int) $this->id)
{
if (!$this->created || $this->created === '0000-00-00 00:00:00')
{
$this->created = $now;
}
}
$this->modified = $now;
return parent::check();
}
/**
* Override delete to clean up junction table rows.
*
* @param mixed $pk Primary key value to delete.
*
* @return boolean True on success.
*
* @since 1.2.0
*/
public function delete($pk = null): bool
{
$pk = $pk ?: $this->id;
if (!parent::delete($pk))
{
return false;
}
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('category_id') . ' = :pk')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
return true;
}
}
@@ -0,0 +1,129 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
/**
* 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();
}
/**
* Override delete to clean up junction table rows.
*
* @param mixed $pk Primary key value to delete.
*
* @return boolean True on success.
*
* @since 1.2.0
*/
public function delete($pk = null): bool
{
$pk = $pk ?: $this->id;
if (!parent::delete($pk))
{
return false;
}
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :pk')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
return true;
}
}
@@ -0,0 +1,51 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Categories list view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public $filterForm;
public $activeFilters;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES'), 'folder');
ToolbarHelper::addNew('category.add');
ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE');
}
}
@@ -0,0 +1,54 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Category edit view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $form;
protected $item;
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
Factory::getApplication()->input->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title(
Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_' . ($isNew ? 'NEW' : 'EDIT')),
'folder'
);
ToolbarHelper::apply('category.apply');
ToolbarHelper::save('category.save');
ToolbarHelper::save2new('category.save2new');
ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -0,0 +1,39 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Import;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Import view for CSV upload.
*
* @since 1.1.0
*/
class HtmlView extends BaseHtmlView
{
/**
* Display the import form.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.1.0
*/
public function display($tpl = null): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT'), 'upload');
parent::display($tpl);
}
}
@@ -0,0 +1,89 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Location edit view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* The form object.
*
* @var \Joomla\CMS\Form\Form
* @since 1.0.0
*/
protected $form;
/**
* The item being edited.
*
* @var object
* @since 1.0.0
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
$this->addToolbar();
parent::display($tpl);
}
/**
* Add the page title and toolbar.
*
* @return void
*
* @since 1.0.0
*/
protected function addToolbar(): void
{
Factory::getApplication()->input->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title(
Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATION_' . ($isNew ? 'NEW' : 'EDIT')),
'location'
);
ToolbarHelper::apply('location.apply');
ToolbarHelper::save('location.save');
ToolbarHelper::save2new('location.save2new');
if (!$isNew)
{
ToolbarHelper::save2copy('location.save2copy');
}
ToolbarHelper::cancel('location.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -0,0 +1,92 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Locations list view for the admin.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array
* @since 1.0.0
*/
protected $items;
/**
* @var \Joomla\CMS\Pagination\Pagination
* @since 1.0.0
*/
protected $pagination;
/**
* @var \Joomla\Registry\Registry
* @since 1.0.0
*/
protected $state;
/**
* @var \Joomla\CMS\Form\Form
* @since 1.0.0
*/
public $filterForm;
/**
* @var array
* @since 1.0.0
*/
public $activeFilters;
/**
* Display the view.
*
* @param string $tpl The name of the template file to parse.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
$this->addToolbar();
parent::display($tpl);
}
/**
* Add the page title and toolbar.
*
* @return void
*
* @since 1.0.0
*/
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'), 'location');
ToolbarHelper::addNew('location.add');
ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
ToolbarHelper::custom('import.display', 'upload', '', 'COM_MOKOJOOMSTORELOCATOR_IMPORT', false);
}
}
@@ -0,0 +1,102 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Categories\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=categories'); ?>"
method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
</div>
<?php else : ?>
<table class="table" id="categoryList">
<caption class="visually-hidden">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION'); ?>
</caption>
<thead>
<tr>
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.checkall'); ?>
</td>
<th scope="col">
<?php echo Text::_('JGLOBAL_TITLE'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR'); ?>
</th>
<th scope="col" class="w-10 text-center d-none d-md-table-cell">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JSTATUS'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JGRID_HEADING_ID'); ?>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) : ?>
<tr class="row<?php echo $i % 2; ?>">
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<?php if ((int) $item->level > 1) : ?>
<?php echo str_repeat('<span class="gi">&mdash;</span>', (int) $item->level - 1); ?>
<?php endif; ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=category.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</th>
<td class="text-center">
<?php if ($item->color) : ?>
<span style="display:inline-block;width:20px;height:20px;border-radius:3px;background-color:<?php echo $this->escape($item->color); ?>;"></span>
<?php else : ?>
&mdash;
<?php endif; ?>
</td>
<td class="text-center d-none d-md-table-cell">
<?php echo (int) $item->location_count; ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'categories.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</div>
</div>
</form>
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Category\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('title'); ?>
<?php echo $this->form->renderField('alias'); ?>
<?php echo $this->form->renderField('parent_id'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('color'); ?>
<?php echo $this->form->renderField('marker_icon'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1,103 @@
<?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 mb-3">
<div class="card-body">
<h4><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE'); ?></h4>
<p class="text-muted"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC'); ?></p>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=import.focalpoint'); ?>"
method="post">
<button type="submit" class="btn btn-outline-primary w-100">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON'); ?>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h4><?php echo Text::_('JHELP'); ?></h4>
<p>Supported column headers (auto-detected):</p>
<ul class="small">
<li><strong>title</strong> / name / store / location (required)</li>
<li>address / street</li>
<li>city</li>
<li>state / province / region</li>
<li>postcode / zip / zipcode / postal_code</li>
<li>country</li>
<li>latitude / lat</li>
<li>longitude / lng / lon</li>
<li>phone / telephone</li>
<li>email</li>
<li>website / url</li>
<li>hours</li>
<li>published (0 or 1)</li>
</ul>
</div>
</div>
</div>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1,81 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
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', 'categories', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('categories'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
<div class="row">
<div class="col-lg-6">
<?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>
@@ -0,0 +1,98 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
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\Locations\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
</div>
<?php else : ?>
<table class="table" id="locationList">
<caption class="visually-hidden">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION'); ?>
</caption>
<thead>
<tr>
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.checkall'); ?>
</td>
<th scope="col">
<?php echo Text::_('JGLOBAL_TITLE'); ?>
</th>
<th scope="col" class="w-10 d-none d-md-table-cell">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CITY'); ?>
</th>
<th scope="col" class="w-10 d-none d-md-table-cell">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_STATE'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JSTATUS'); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('JGRID_HEADING_ID'); ?>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) : ?>
<tr class="row<?php echo $i % 2; ?>">
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=location.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<?php if ($item->alias) : ?>
<div class="small"><?php echo $this->escape($item->alias); ?></div>
<?php endif; ?>
</th>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->city); ?>
</td>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->state); ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</div>
</div>
</form>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\ApiController;
/**
* REST API controller for locations.
*
* @since 1.2.0
*/
class LocationsController extends ApiController
{
/**
* The content type.
*
* @var string
* @since 1.2.0
*/
protected $contentType = 'locations';
/**
* The default view.
*
* @var string
* @since 1.2.0
*/
protected $default_view = 'locations';
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Api\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
/**
* JSON:API view for locations.
*
* @since 1.2.0
*/
class JsonapiView extends BaseApiView
{
/**
* Fields to render for a single item.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderItem = [
'id',
'title',
'alias',
'description',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'email',
'website',
'hours',
'image',
'published',
];
/**
* Fields to render for a list.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderList = [
'id',
'title',
'alias',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'published',
];
}
@@ -0,0 +1,77 @@
<?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.01.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>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site">
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<administration>
<files folder="admin">
<filename>access.xml</filename>
<folder>forms</folder>
<folder>language</folder>
<folder>services</folder>
<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=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu>
</administration>
</extension>
@@ -0,0 +1,21 @@
; MokoSuiteStoreLocator - Site language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
COM_MOKOSUITESTORELOCATOR="MokoSuite Store Locator"
COM_MOKOJOOMSTORELOCATOR="Store Locator"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE="All Locations"
COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC="Displays a list of all store locations."
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE="Location Detail"
COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC="Displays a single store location with full details."
COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION="Select Location"
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
@@ -0,0 +1,29 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Default site controller.
*
* @since 1.0.0
*/
class DisplayController extends BaseController
{
/**
* The default view.
*
* @var string
* @since 1.0.0
*/
protected $default_view = 'locations';
}
@@ -0,0 +1,81 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\Database\ParameterType;
/**
* Single location model for the site frontend.
*
* @since 1.0.0
*/
class LocationModel extends ItemModel
{
/**
* The location item.
*
* @var object|null
* @since 1.0.0
*/
protected $_item = null;
/**
* Get a single location item.
*
* @param integer $pk The item primary key. If null, uses the model state.
*
* @return object|null The location object or null if not found.
*
* @since 1.0.0
*/
public function getItem($pk = null)
{
$pk = $pk ?: (int) $this->getState('location.id');
if ($this->_item === null)
{
$this->_item = [];
}
if (!isset($this->_item[$pk]))
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.id') . ' = :pk')
->where($db->quoteName('a.published') . ' = 1')
->bind(':pk', $pk, ParameterType::INTEGER);
$db->setQuery($query);
$this->_item[$pk] = $db->loadObject();
}
return $this->_item[$pk] ?? null;
}
/**
* Populate the model state.
*
* @return void
*
* @since 1.0.0
*/
protected function populateState()
{
$app = $this->getApplication();
$id = $app->input->getInt('id', 0);
$this->setState('location.id', $id);
}
}
@@ -0,0 +1,215 @@
<?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');
$catid = $app->input->getInt('catid', 0);
$this->setState('filter.catid', $catid);
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);
}
// Category filter — use EXISTS to avoid GROUP BY / ONLY_FULL_GROUP_BY issues
$catid = (int) $this->getState('filter.catid');
if ($catid > 0)
{
$subQuery = $db->getQuery(true)
->select('1')
->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc'))
->where($db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->where($db->quoteName('lc.category_id') . ' = :catid');
$query->where('EXISTS (' . $subQuery . ')')
->bind(':catid', $catid, ParameterType::INTEGER);
}
// Proximity / Haversine distance filter
$lat = $this->getState('filter.lat');
$lng = $this->getState('filter.lng');
$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 — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
}
return $query;
}
}
@@ -0,0 +1,51 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
/**
* SEF URL router for the store locator component.
*
* @since 1.0.0
*/
class Router extends RouterView
{
/**
* Constructor.
*
* @param SiteApplication $app The application object.
* @param AbstractMenu $menu The menu object.
*
* @since 1.0.0
*/
public function __construct(SiteApplication $app, AbstractMenu $menu)
{
$locations = new RouterViewConfiguration('locations');
$this->registerView($locations);
$location = new RouterViewConfiguration('location');
$location->setKey('id')->setParent($locations);
$this->registerView($location);
parent::__construct($app, $menu);
$this->attachRule(new MenuRules($this));
$this->attachRule(new StandardRules($this));
$this->attachRule(new NomenuRules($this));
}
}
@@ -0,0 +1,48 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Single location detail view for the site frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var object|null
* @since 1.0.0
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->item = $this->get('Item');
if ($this->item === null)
{
throw new \Exception('Location not found', 404);
}
parent::display($tpl);
}
}
@@ -0,0 +1,57 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Site\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Locations list view for the site frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array
* @since 1.0.0
*/
protected $items;
/**
* @var \Joomla\CMS\Pagination\Pagination
* @since 1.0.0
*/
protected $pagination;
/**
* @var \Joomla\Registry\Registry
* @since 1.0.0
*/
protected $state;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
parent::display($tpl);
}
}
@@ -0,0 +1,147 @@
<?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;
if ($item->latitude && $item->longitude)
{
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], ['integrity' => 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=', 'crossorigin' => '']);
$wa->registerAndUseScript('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', [], ['integrity' => 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', 'crossorigin' => '', 'defer' => true]);
}
?>
<div class="com-mokosuitestorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<?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"
id="mokosuitestorelocator-detail-map"
data-lat="<?php echo (float) $item->latitude; ?>"
data-lng="<?php echo (float) $item->longitude; ?>"
data-title="<?php echo $this->escape($item->title); ?>"
style="height: 300px;">
</div>
<?php
$wa->addInlineScript(<<<JS
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('mokosuitestorelocator-detail-map');
if (!el || typeof L === 'undefined') return;
var lat = parseFloat(el.getAttribute('data-lat'));
var lng = parseFloat(el.getAttribute('data-lng'));
var map = L.map(el.id).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
var span = document.createElement('span');
span.textContent = el.getAttribute('data-title') || '';
L.marker([lat, lng]).addTo(map).bindPopup(span).openPopup();
});
JS, [], ['position' => 'after'], ['leaflet']);
?>
<?php endif; ?>
</div>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC]]>
</message>
</layout>
<fields name="request">
<fieldset name="request">
<field
name="id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION"
query="SELECT id, title FROM #__mokosuitestorelocator_locations WHERE published = 1 ORDER BY title"
key_field="id"
value_field="title"
required="true"
/>
</fieldset>
</fields>
</metadata>
@@ -0,0 +1,70 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Locations\HtmlView $this */
?>
<div class="com-mokosuitestorelocator-locations">
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS'); ?></p>
<?php else : ?>
<div class="com-mokosuitestorelocator-locations__list">
<?php foreach ($this->items as $item) : ?>
<div class="com-mokosuitestorelocator-location-card" itemscope itemtype="https://schema.org/LocalBusiness">
<?php if ($item->image) : ?>
<div class="com-mokosuitestorelocator-location-card__image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" loading="lazy">
</div>
<?php endif; ?>
<div class="com-mokosuitestorelocator-location-card__body">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=location&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<div itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
</div>
<?php if ($item->phone) : ?>
<div class="com-mokosuitestorelocator-location-card__phone">
<?php $safePhone = preg_replace('/[^0-9+\-() ]/', '', $item->phone); ?>
<a href="tel:<?php echo $this->escape($safePhone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
</div>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC]]>
</message>
</layout>
</metadata>
@@ -0,0 +1,14 @@
; MokoSuiteStoreLocator Map Module - Language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_MAP="MokoSuite Store Locator - Map"
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
MOD_MOKOJOOMSTORELOCATOR_MAP_HEIGHT="Map Height"
MOD_MOKOJOOMSTORELOCATOR_MAP_ZOOM="Default Zoom Level"
MOD_MOKOJOOMSTORELOCATOR_MAP_PROVIDER="Map Provider"
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY="API Key"
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC="Required for Google Maps. Not needed for OpenStreetMap."
MOD_MOKOJOOMSTORELOCATOR_MAP_CLUSTERING="Enable Marker Clustering"
MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT="JavaScript is required to display the map."
@@ -0,0 +1,7 @@
; MokoSuiteStoreLocator Map Module - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_MAP="MokoSuite Store Locator - Map"
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
@@ -0,0 +1,84 @@
<?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: mod_mokosuitestorelocator_map
PATH: src/packages/mod_mokosuitestorelocator_map/mod_mokosuitestorelocator_map.xml
VERSION: 01.00.00
BRIEF: Module manifest for the store locator map module
=========================================================================
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_map</name>
<version>01.01.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>MOD_MOKOJOOMSTORELOCATOR_MAP_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorMap</namespace>
<files>
<folder module="mod_mokosuitestorelocator_map">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
<folder>language</folder>
</files>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="map_height"
type="text"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_HEIGHT"
default="400px"
/>
<field
name="map_zoom"
type="number"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_ZOOM"
default="10"
min="1"
max="20"
/>
<field
name="map_provider"
type="list"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_PROVIDER"
default="leaflet"
>
<option value="leaflet">OpenStreetMap (Leaflet)</option>
<option value="google">Google Maps</option>
</field>
<field
name="api_key"
type="text"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY"
description="MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC"
/>
<field
name="enable_clustering"
type="radio"
label="MOD_MOKOJOOMSTORELOCATOR_MAP_CLUSTERING"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,25 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_map
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoSuiteStoreLocatorMap'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoSuiteStoreLocatorMap\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,101 @@
<?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('a.id'),
$db->quoteName('a.title'),
$db->quoteName('a.address'),
$db->quoteName('a.city'),
$db->quoteName('a.state'),
$db->quoteName('a.postcode'),
$db->quoteName('a.phone'),
$db->quoteName('a.latitude'),
$db->quoteName('a.longitude'),
])
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.published') . ' = 1')
->where($db->quoteName('a.latitude') . ' IS NOT NULL')
->where($db->quoteName('a.longitude') . ' IS NOT NULL');
// Join to get primary category marker icon
$query->select([$db->quoteName('c.marker_icon'), $db->quoteName('c.color', 'cat_color')])
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')
. ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_categories', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('lc.category_id')
. ' AND ' . $db->quoteName('c.published') . ' = 1')
->group($db->quoteName('a.id'));
$db->setQuery($query);
$locations = $db->loadObjectList() ?: [];
$markers = [];
foreach ($locations as $loc)
{
$marker = [
'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,
];
if (!empty($loc->marker_icon))
{
$marker['marker_icon'] = $loc->marker_icon;
}
if (!empty($loc->cat_color))
{
$marker['cat_color'] = $loc->cat_color;
}
$markers[] = $marker;
}
$data['locations'] = $markers;
return $data;
}
}
@@ -0,0 +1,115 @@
<?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');
if (!preg_match('/^\d+(px|em|rem|vh|%)$/', $mapHeight))
{
$mapHeight = '400px';
}
$mapZoom = (int) $params->get('map_zoom', 10);
$provider = $params->get('map_provider', 'leaflet');
$apiKey = $params->get('api_key', '');
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $displayData['app']->getDocument()->getWebAssetManager();
$enableClustering = (bool) $params->get('enable_clustering', 1);
if ($provider === 'leaflet')
{
$wa->registerAndUseStyle('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], ['integrity' => 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=', 'crossorigin' => '']);
$wa->registerAndUseScript('leaflet', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', [], ['integrity' => 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', 'crossorigin' => '', 'defer' => true]);
if ($enableClustering)
{
$wa->registerAndUseStyle('leaflet.markercluster', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css', [], ['crossorigin' => '']);
$wa->registerAndUseStyle('leaflet.markercluster.default', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css', [], ['crossorigin' => '']);
$wa->registerAndUseScript('leaflet.markercluster', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js', [], ['crossorigin' => '', 'defer' => true], ['leaflet']);
}
}
?>
<div class="mod-mokosuitestorelocator-map"
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>
<?php
$directionsText = Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true);
$clusteringJs = $enableClustering ? 'true' : 'false';
$wa->addInlineScript(<<<JS
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('mokosuitestorelocator-map-{$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();
var useClustering = {$clusteringJs} && typeof L.markerClusterGroup === 'function';
var markerLayer = useClustering ? L.markerClusterGroup() : L.layerGroup();
function esc(str) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(str || ''));
return d.innerHTML;
}
locations.forEach(function(loc) {
var markerOptions = {};
if (loc.marker_icon) {
markerOptions.icon = L.icon({
iconUrl: loc.marker_icon,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
}
var marker = L.marker([loc.lat, loc.lng], markerOptions);
var popup = '<strong>' + esc(loc.title) + '</strong>';
if (loc.address) popup += '<br>' + esc(loc.address);
if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>';
popup += '<br><a href="https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng + '" target="_blank" rel="noopener">{$directionsText}</a>';
marker.bindPopup(popup);
markerLayer.addLayer(marker);
bounds.extend([loc.lat, loc.lng]);
});
map.addLayer(markerLayer);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: zoom });
});
JS, [], ['position' => 'after'], ['leaflet']);
?>
@@ -0,0 +1,21 @@
; MokoSuiteStoreLocator Search Module - Language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_SEARCH="MokoSuite Store Locator - Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL="Find a Store"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER="Enter city, postcode, or address..."
MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_CITY="Show City Filter"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_CITY="City"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_ALL_CITIES="All Cities"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS="Distance"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_ANY_DISTANCE="Any Distance"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Locating..."
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_SET="Location Set"
@@ -0,0 +1,7 @@
; MokoSuiteStoreLocator Search Module - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
MOD_MOKOSUITESTORELOCATOR_SEARCH="MokoSuite Store Locator - Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
@@ -0,0 +1,80 @@
<?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: mod_mokosuitestorelocator_search
PATH: src/packages/mod_mokosuitestorelocator_search/mod_mokosuitestorelocator_search.xml
VERSION: 01.00.00
BRIEF: Module manifest for the store locator search module
=========================================================================
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokosuitestorelocator_search</name>
<version>01.01.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>MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteStoreLocatorSearch</namespace>
<files>
<folder module="mod_mokosuitestorelocator_search">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
<folder>language</folder>
</files>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="show_city_filter"
type="radio"
label="MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_CITY"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="show_radius_filter"
type="radio"
label="MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="radius_unit"
type="list"
label="MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT"
default="miles"
>
<option value="miles">Miles</option>
<option value="km">Kilometres</option>
</field>
<field
name="radius_options"
type="text"
label="MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS"
default="5,10,25,50,100"
description="MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,25 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_search
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoSuiteStoreLocatorSearch'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoSuiteStoreLocatorSearch\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,72 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_search
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoSuiteStoreLocatorSearch\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
/**
* Dispatcher for mod_mokosuitestorelocator_search.
*
* @since 1.0.0
*/
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface, DatabaseAwareInterface
{
use HelperFactoryAwareTrait;
use DatabaseAwareTrait;
/**
* Returns the layout data.
*
* @return array
*
* @since 1.0.0
*/
protected function getLayoutData(): array
{
$data = parent::getLayoutData();
$params = $data['params'];
$db = $this->getDatabase();
// Load distinct cities
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('city'))
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('city') . " != ''")
->order($db->quoteName('city') . ' ASC');
$db->setQuery($query);
$data['cities'] = $db->loadColumn() ?: [];
// Load distinct states
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('state'))
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('state') . " != ''")
->order($db->quoteName('state') . ' ASC');
$db->setQuery($query);
$data['states'] = $db->loadColumn() ?: [];
// Build radius options from params
$radiusStr = $params->get('radius_options', '5,10,25,50,100');
$data['radiusOptions'] = array_map('intval', array_filter(explode(',', $radiusStr)));
$data['radiusUnit'] = $params->get('radius_unit', 'miles');
return $data;
}
}
@@ -0,0 +1,121 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage mod_mokosuitestorelocator_search
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var array $displayData */
$params = $displayData['params'];
$cities = $displayData['cities'] ?? [];
$states = $displayData['states'] ?? [];
$radiusOptions = $displayData['radiusOptions'] ?? [];
$radiusUnit = $displayData['radiusUnit'] ?? 'miles';
$showCity = (int) $params->get('show_city_filter', 1);
$showRadius = (int) $params->get('show_radius_filter', 1);
$moduleId = $displayData['module']->id;
?>
<div class="mod-mokosuitestorelocator-search">
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=locations'); ?>"
method="get" class="mokosuitestorelocator-search-form" id="mokosuitestorelocator-search-<?php echo (int) $moduleId; ?>">
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
</label>
<input type="text"
id="mokosuitestorelocator-query-<?php echo (int) $moduleId; ?>"
name="search"
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
class="form-control"
/>
</div>
<?php if ($showCity && !empty($cities)) : ?>
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_CITY'); ?>
</label>
<select id="mokosuitestorelocator-city-<?php echo (int) $moduleId; ?>"
name="city" class="form-select">
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ALL_CITIES'); ?></option>
<?php foreach ($cities as $city) : ?>
<option value="<?php echo $this->escape($city); ?>">
<?php echo $this->escape($city); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if ($showRadius && !empty($radiusOptions)) : ?>
<div class="mokosuitestorelocator-search-field">
<label for="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS'); ?>
</label>
<select id="mokosuitestorelocator-radius-<?php echo (int) $moduleId; ?>"
name="radius" class="form-select">
<option value=""><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_ANY_DISTANCE'); ?></option>
<?php foreach ($radiusOptions as $radius) : ?>
<option value="<?php echo (int) $radius; ?>">
<?php echo (int) $radius . ' ' . $this->escape($radiusUnit); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" name="lat" id="mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>" value="" />
<input type="hidden" name="lng" id="mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>" value="" />
<input type="hidden" name="radius_unit" value="<?php echo $this->escape($radiusUnit); ?>" />
<button type="button" class="btn btn-outline-secondary mokosuitestorelocator-geolocation-btn"
id="mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>
</button>
<?php endif; ?>
<button type="submit" class="btn btn-primary">
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
<input type="hidden" name="option" value="com_mokosuitestorelocator" />
<input type="hidden" name="view" value="locations" />
</form>
</div>
<?php if ($showRadius) : ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokosuitestorelocator-geolocate-<?php echo (int) $moduleId; ?>');
if (!btn || !navigator.geolocation) {
if (btn) btn.style.display = 'none';
return;
}
btn.addEventListener('click', function() {
btn.disabled = true;
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING', true); ?>';
navigator.geolocation.getCurrentPosition(
function(pos) {
document.getElementById('mokosuitestorelocator-lat-<?php echo (int) $moduleId; ?>').value = pos.coords.latitude;
document.getElementById('mokosuitestorelocator-lng-<?php echo (int) $moduleId; ?>').value = pos.coords.longitude;
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_SET', true); ?>';
btn.disabled = false;
},
function() {
btn.textContent = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION', true); ?>';
btn.disabled = false;
},
{ enableHighAccuracy: false, timeout: 10000 }
);
});
});
</script>
<?php endif; ?>
@@ -0,0 +1,2 @@
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuite Store Locator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
@@ -0,0 +1,6 @@
; MokoSuiteStoreLocator Web Services Plugin - System language strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GNU General Public License version 3 or later; see LICENSE
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuite Store Locator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."

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