diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 04fd505..8314589 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -148,6 +148,9 @@ jobs: if match: block = match.group(1) updated = block.replace(old, new) + import datetime + today = datetime.date.today().isoformat() + updated = re.sub(r"[^<]*", f"{today}", updated) content = content.replace(block, updated) with open("updates.xml", "w") as f: f.write(content) diff --git a/.gitea/workflows/update-server.yml b/.gitea/workflows/update-server.yml new file mode 100644 index 0000000..98d2271 --- /dev/null +++ b/.gitea/workflows/update-server.yml @@ -0,0 +1,495 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Joomla +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/joomla/update-server.yml.template +# VERSION: 04.06.00 +# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries +# +# Writes updates.xml with multiple entries: +# - stable on push to main (from auto-release) +# - rc on push to rc/** +# - development on push to dev or dev/** +# +# Joomla filters by user's "Minimum Stability" setting. + +name: Update Joomla Update Server XML Feed + +on: + push: + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + pull_request: + types: [closed] + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + inputs: + stability: + description: 'Stability tag' + required: true + default: 'development' + type: choice + options: + - development + - alpha + - beta + - rc + - stable + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + update-xml: + name: Update updates.xml + runs-on: release + if: >- + github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.GA_TOKEN }} + fetch-depth: 0 + + - name: Setup MokoStandards tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api 2>/dev/null || true + if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then + cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true + fi + + - name: Generate updates.xml entry + id: update + run: | + BRANCH="${{ github.ref_name }}" + REPO="${{ github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0") + + # Auto-bump patch on all branches (dev, alpha, beta, rc) + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true) + if [ -n "$BUMPED" ]; then + VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION") + git add -A + git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \ + --author="gitea-actions[bot] " 2>/dev/null || true + git push 2>/dev/null || true + fi + + # Determine stability from branch or input + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + STABILITY="${{ inputs.stability }}" + elif [[ "$BRANCH" == rc/* ]]; then + STABILITY="rc" + elif [[ "$BRANCH" == beta/* ]]; then + STABILITY="beta" + elif [[ "$BRANCH" == alpha/* ]]; then + STABILITY="alpha" + elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then + STABILITY="development" + else + STABILITY="stable" + fi + + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + + # Parse manifest (portable — no grep -P) + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "No Joomla manifest found — skipping" + exit 0 + fi + + # Extract fields using sed (works on all runners) + EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) + EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) + TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) + PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) + + # Fallbacks + [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" + [ -z "$EXT_TYPE" ] && EXT_TYPE="component" + + # Derive element if not in manifest: try XML filename, then repo name + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + case "$EXT_ELEMENT" in + templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; + esac + fi + + # Use manifest version if README version is empty + [ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION" + + [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/") + + CLIENT_TAG="" + [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT}" + [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site" + + FOLDER_TAG="" + [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}" + + PHP_TAG="" + [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM}" + + # Version suffix for non-stable + DISPLAY_VERSION="$VERSION" + case "$STABILITY" in + development) DISPLAY_VERSION="${VERSION}-dev" ;; + alpha) DISPLAY_VERSION="${VERSION}-alpha" ;; + beta) DISPLAY_VERSION="${VERSION}-beta" ;; + rc) DISPLAY_VERSION="${VERSION}-rc" ;; + esac + + MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') + + # Each stability level has its own release tag + case "$STABILITY" in + development) RELEASE_TAG="development" ;; + alpha) RELEASE_TAG="alpha" ;; + beta) RELEASE_TAG="beta" ;; + rc) RELEASE_TAG="release-candidate" ;; + *) RELEASE_TAG="v${MAJOR}" ;; + esac + + PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip" + DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" + INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}" + + # -- Build install packages (ZIP + tar.gz) -------------------- + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ -d "$SOURCE_DIR" ]; then + EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" + TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz" + + cd "$SOURCE_DIR" + zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES + cd .. + tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ + --exclude='.ftpignore' --exclude='sftp-config*' \ + --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + + SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1) + + # Ensure release exists on Gitea + RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true) + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -z "$RELEASE_ID" ]; then + # Create release + RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/releases" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'tag_name': '${RELEASE_TAG}', + 'name': '${RELEASE_TAG} (${DISPLAY_VERSION})', + 'body': '${STABILITY} release', + 'prerelease': True, + 'target_commitish': 'main' + }))")" 2>/dev/null || true) + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + fi + + if [ -n "$RELEASE_ID" ]; then + # Delete existing assets with same name before uploading + ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]") + for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do + ASSET_ID=$(echo "$ASSETS" | python3 -c " + import sys,json + assets = json.load(sys.stdin) + for a in assets: + if a['name'] == '${ASSET_FILE}': + print(a['id']); break + " 2>/dev/null || true) + if [ -n "$ASSET_ID" ]; then + curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true + fi + done + + # Upload both formats + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${PACKAGE_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true + + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${TAR_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true + fi + + echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY + else + SHA256="" + fi + + # -- Build the new entry ----------------------------------------- + NEW_ENTRY="" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME}\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} (${STABILITY})\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT}\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE}\n" + NEW_ENTRY="${NEW_ENTRY} ${DISPLAY_VERSION}\n" + NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d)\n" + [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n" + [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${STABILITY}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${INFO_URL}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256}\n" + NEW_ENTRY="${NEW_ENTRY} ${TARGET_PLATFORM}\n" + [ -n "$PHP_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_TAG}\n" + NEW_ENTRY="${NEW_ENTRY} Moko Consulting\n" + NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech\n" + NEW_ENTRY="${NEW_ENTRY} " + + # -- Write new entry to temp file -------------------------------- + printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml + + # -- Merge into updates.xml (only update this stability channel) - + # Cascading update: each stability level updates itself and all lower levels + # stable → all | rc → rc,beta,alpha,dev | beta → beta,alpha,dev | alpha → alpha,dev | dev → dev + CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development" + TARGETS="" + for entry in $CASCADE_MAP; do + key="${entry%%:*}" + vals="${entry#*:}" + if [ "$key" = "${STABILITY}" ]; then + TARGETS="$vals" + break + fi + done + [ -z "$TARGETS" ] && TARGETS="${STABILITY}" + + if [ ! -f "updates.xml" ]; then + printf '%s\n' "" > updates.xml + printf '%s\n' "" >> updates.xml + printf '%s\n' "" >> updates.xml + printf '%s\n' '' >> updates.xml + cat /tmp/new_entry.xml >> updates.xml + printf '\n%s\n' '' >> updates.xml + else + # Replace each cascading channel with the new entry (different tag) + export PY_TARGETS="$TARGETS" + python3 << PYEOF + import re, os + targets = os.environ["PY_TARGETS"].split(",") + stability = "${STABILITY}" + with open("updates.xml") as f: + content = f.read() + with open("/tmp/new_entry.xml") as f: + new_entry_template = f.read() + for tag in targets: + tag = tag.strip() + # Build entry with this tag + new_entry = re.sub(r"[^<]*", f"{tag}", new_entry_template) + # Remove existing entry for this tag + pattern = r" .*?" + re.escape(tag) + r".*?\n?" + content = re.sub(pattern, "", content, flags=re.DOTALL) + # Insert before + content = content.replace("", new_entry + "\n") + content = re.sub(r"\n{3,}", "\n\n", content) + with open("updates.xml", "w") as f: + f.write(content) + PYEOF + if [ $? -ne 0 ]; then + # Fallback: rebuild keeping other stability entries + { + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' '' + for TAG in stable rc development; do + [ "$TAG" = "${STABILITY}" ] && continue + if grep -q "${TAG}" updates.xml 2>/dev/null; then + sed -n "//,/<\/update>/{ /${TAG}<\/tag>/p; }" updates.xml + fi + done + cat /tmp/new_entry.xml + printf '\n%s\n' '' + } > /tmp/updates_new.xml + mv /tmp/updates_new.xml updates.xml + fi + fi + + # Commit + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git add updates.xml + git diff --cached --quiet || { + git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \ + --author="gitea-actions[bot] " + git push + } + + # -- Sync updates.xml to main (for non-main branches) ---------------------- + - name: Sync updates.xml to main + if: github.ref_name != 'main' + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + GA_TOKEN="${{ secrets.GA_TOKEN }}" + + FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \ + "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) + + if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then + CONTENT=$(base64 -w0 updates.xml) + curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/contents/updates.xml" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'content': '${CONTENT}', + 'sha': '${FILE_SHA}', + 'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]', + 'branch': 'main' + }))")" > /dev/null 2>&1 \ + && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \ + || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY + else + echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY + fi + + # -- Mirror to GitHub (stable and rc only) -------------------------------- + - name: Mirror release to GitHub + if: >- + (steps.update.outputs.stability == 'stable' || steps.update.outputs.stability == 'rc') && + secrets.GH_TOKEN != '' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + STABILITY="${{ steps.update.outputs.stability }}" + echo "GitHub mirror sync for ${STABILITY} — ${GH_REPO}" >> $GITHUB_STEP_SUMMARY + # Mirror packages if they exist + for PKG in /tmp/*.zip /tmp/*.tar.gz; do + if [ -f "$PKG" ]; then + _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/${RELEASE_TAG}" 2>/dev/null | jq -r ".id // empty") + [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true + fi + done + + - name: SFTP deploy to dev server + if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev' + env: + DEV_HOST: ${{ vars.DEV_FTP_HOST }} + DEV_PATH: ${{ vars.DEV_FTP_PATH }} + DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }} + DEV_USER: ${{ vars.DEV_FTP_USERNAME }} + DEV_PORT: ${{ vars.DEV_FTP_PORT }} + DEV_KEY: ${{ secrets.DEV_FTP_KEY }} + DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }} + run: | + # -- Permission check: admin or maintain role required -------- + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read") + case "$PERMISSION" in + admin|maintain|write) ;; + *) + echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write" + exit 0 + ;; + esac + + [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; } + + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && exit 0 + + PORT="${DEV_PORT:-22}" + REMOTE="${DEV_PATH%/}" + [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}" + + printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ + "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json + if [ -n "$DEV_KEY" ]; then + echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key + printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json + else + printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json + fi + + PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) + if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then + php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then + php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + fi + rm -f /tmp/deploy_key /tmp/sftp-config.json + echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY + + - name: Summary + if: always() + run: | + echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index b8e9f8e..424371b 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -330,6 +330,8 @@ jobs: INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable" # -- Build update entry for a given stability tag + CREATION_DATE=$(date +%Y-%m-%d) + build_entry() { local TAG_NAME="$1" printf '%s\n' ' ' @@ -338,6 +340,7 @@ jobs: printf '%s\n' " ${EXT_ELEMENT}" printf '%s\n' " ${EXT_TYPE}" printf '%s\n' " ${VERSION}" + printf '%s\n' " ${CREATION_DATE}" [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" printf '%s\n' " ${TAG_NAME}" diff --git a/.github/workflows/update-server.yml b/.github/workflows/update-server.yml index f2972eb..98d2271 100644 --- a/.github/workflows/update-server.yml +++ b/.github/workflows/update-server.yml @@ -278,6 +278,7 @@ jobs: NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT}\n" NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE}\n" NEW_ENTRY="${NEW_ENTRY} ${DISPLAY_VERSION}\n" + NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d)\n" [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n" [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n" NEW_ENTRY="${NEW_ENTRY} \n" diff --git a/README.md b/README.md index 4f8cc42..185f14e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ INGROUP: MokoOnyx.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx FILE: ./README.md - VERSION: 01.00.19 + VERSION: 01.00.25 BRIEF: Documentation for MokoOnyx template --> diff --git a/src/.build-trigger b/src/.build-trigger new file mode 100644 index 0000000..a60fd55 --- /dev/null +++ b/src/.build-trigger @@ -0,0 +1 @@ +2026-04-26 diff --git a/src/html/com_joomgallery/category/default.php b/src/html/com_joomgallery/category/default.php new file mode 100644 index 0000000..a3118e2 --- /dev/null +++ b/src/html/com_joomgallery/category/default.php @@ -0,0 +1,51 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoOnyx.Override + * INGROUP: MokoOnyx + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx + * PATH: /html/com_joomgallery/category/default.php + * VERSION: 01.00.00 + * BRIEF: Category view override — password gate then loads default_cat sub-layout + */ + +// No direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Router\Route; +use Joomla\CMS\Language\Text; +use Joomla\CMS\HTML\HTMLHelper; + +// Import CSS & JS +$wa = $this->document->getWebAssetManager(); +$wa->useStyle('com_joomgallery.site'); +$wa->useStyle('com_joomgallery.jg-icon-font'); +?> + +item->pw_protected) : ?> +
+
+
+

+
+
+ + +
+
+ +
+ +
+
+ + loadTemplate('cat'); ?> + diff --git a/src/html/com_joomgallery/category/default_cat.php b/src/html/com_joomgallery/category/default_cat.php new file mode 100644 index 0000000..fbbcad2 --- /dev/null +++ b/src/html/com_joomgallery/category/default_cat.php @@ -0,0 +1,219 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoOnyx.Override + * INGROUP: MokoOnyx + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx + * PATH: /html/com_joomgallery/category/default_cat.php + * VERSION: 01.00.00 + * BRIEF: Category sub-layout — subcategories grid + images grid with pagination + */ + +// No direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Session\Session; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Layout\LayoutHelper; +use Joomgallery\Component\Joomgallery\Administrator\Helper\JoomHelper; + +// Image params +$image_type = $this->params['configs']->get('jg_category_view_type_image', 'thumbnail', 'STRING'); +$gallery_class = $this->params['configs']->get('jg_category_view_class', 'masonry', 'STRING'); +$num_columns = $this->params['configs']->get('jg_category_view_num_columns', 3, 'INT'); +$image_class = $this->params['configs']->get('jg_category_view_image_class', 0, 'INT'); +$justified_height = $this->params['configs']->get('jg_category_view_justified_height', 200, 'INT'); +$justified_gap = $this->params['configs']->get('jg_category_view_justified_gap', 5, 'INT'); +$image_link = $this->params['configs']->get('jg_category_view_image_link', 'defaultview', 'STRING'); +$lightbox_image = $this->params['configs']->get('jg_category_view_lightbox_image', 'detail', 'STRING'); +$pagination_type = $this->params['configs']->get('jg_category_view_pagination', 0, 'INT'); +$show_subcategories = $this->params['configs']->get('jg_category_view_subcategories', 1, 'INT'); +$subcategory_type_image = $this->params['configs']->get('jg_category_view_type_subcategory_image', 'thumbnail', 'STRING'); +$num_columns_subcats = $this->params['configs']->get('jg_category_view_subcategories_num_columns', 3, 'INT'); + +// Import CSS & JS +$wa = $this->document->getWebAssetManager(); + +if ($gallery_class == 'masonry') { + $wa->useScript('com_joomgallery.masonry'); +} + +if ($gallery_class == 'justified') { + $wa->useScript('com_joomgallery.justified'); + $wa->addInlineStyle('.jg-images[class*=" justified-"] .jg-image-caption-hover { right: ' . $justified_gap . 'px; }'); +} + +$lightbox = false; +if ($image_link == 'lightgallery') { + $lightbox = true; + $wa->useScript('com_joomgallery.lightgallery'); + $wa->useScript('com_joomgallery.lg-thumbnail'); + $wa->useStyle('com_joomgallery.lightgallery-bundle'); +} + +// Initialise the grid script +$iniJS = 'window.joomGrid = {'; +$iniJS .= ' itemid: ' . $this->item->id . ','; +$iniJS .= ' pagination: ' . $pagination_type . ','; +$iniJS .= ' layout: "' . $gallery_class . '",'; +$iniJS .= ' num_columns: ' . $num_columns . ','; +$iniJS .= ' lightbox: ' . ($lightbox ? 'true' : 'false') . ','; +$iniJS .= ' justified: {height: ' . $justified_height . ', gap: ' . $justified_gap . '}'; +$iniJS .= '};'; + +$wa->addInlineScript($iniJS, ['position' => 'before'], [], ['com_joomgallery.joomgrid']); +$wa->useScript('com_joomgallery.joomgrid'); + +// Access check +$canEdit = $this->getAcl()->checkACL('edit', 'com_joomgallery.category', $this->item->id, $this->item->parent_id, true); +$canAdd = $this->getAcl()->checkACL('add', 'com_joomgallery.image', 0, $this->item->id, true); +$canDelete = $this->getAcl()->checkACL('delete', 'com_joomgallery.category', $this->item->id, $this->item->parent_id, true); +$canCheckin = $this->getAcl()->checkACL('editstate', 'com_joomgallery.category', $this->item->id, $this->item->parent_id, true) || $this->item->checked_out == Factory::getUser()->id; +$returnURL = base64_encode(JoomHelper::getViewRoute('category', $this->item->id, $this->item->parent_id, $this->item->language, $this->getLayout())); + +$hasSubcats = $show_subcategories && !empty($this->item->children->items) && count($this->item->children->items) > 0; +$hasImages = !empty($this->item->images->items) && count($this->item->images->items) > 0; +?> + +
+ + params['menu']->get('show_page_heading')) : ?> + + + + +

escape($this->item->title); ?>

+ + + item->description)) : ?> +
+ item->description; ?> +
+ + + + + + + + + +
+

+ (int) $this->item->id, + 'items' => $this->item->children->items, + 'num_columns' => (int) $num_columns_subcats, + 'image_type' => $subcategory_type_image, + ]; + echo LayoutHelper::render('joomgallery.grids.categories', $catsData); + ?> +
+ + + + +
+ +

+ + + (int) $this->item->id, + 'layout' => $gallery_class, + 'items' => $this->item->images->items, + 'num_columns' => (int) $num_columns, + 'caption_align' => 'center', + 'image_class' => $image_class, + 'image_type' => $image_type, + 'lightbox_type' => $lightbox_image, + 'image_link' => $image_link, + 'image_title' => false, + 'title_link' => 'defaultview', + 'image_desc' => false, + 'image_date' => false, + 'image_author' => false, + 'image_tags' => false, + ]; + echo LayoutHelper::render('joomgallery.grids.images', $imgsData); + ?> + + + +
+ + + + + + item->parent_id > 0 && $this->item->parent_id != 1) : ?> +
+ + + +
+ + + +
diff --git a/src/html/com_joomgallery/category/index.html b/src/html/com_joomgallery/category/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/html/com_joomgallery/category/index.html @@ -0,0 +1 @@ + diff --git a/src/html/com_joomgallery/gallery/default.php b/src/html/com_joomgallery/gallery/default.php new file mode 100644 index 0000000..8eb0aae --- /dev/null +++ b/src/html/com_joomgallery/gallery/default.php @@ -0,0 +1,138 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoOnyx.Override + * INGROUP: MokoOnyx + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx + * PATH: /html/com_joomgallery/gallery/default.php + * VERSION: 01.00.00 + * BRIEF: Gallery view override — main image grid with masonry/justified layout + */ + +// No direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Router\Route; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; + +// Image params +$image_type = $this->params['configs']->get('jg_gallery_view_type_image', 'thumbnail', 'STRING'); +$gallery_class = $this->params['configs']->get('jg_gallery_view_class', 'masonry', 'STRING'); +$num_columns = $this->params['configs']->get('jg_gallery_view_num_columns', 3, 'INT'); +$image_class = $this->params['configs']->get('jg_gallery_view_image_class', 0, 'INT'); +$justified_height = $this->params['configs']->get('jg_gallery_view_justified_height', 200, 'INT'); +$justified_gap = $this->params['configs']->get('jg_gallery_view_justified_gap', 5, 'INT'); +$image_link = $this->params['configs']->get('jg_gallery_view_image_link', 'defaultview', 'STRING'); +$lightbox_image = $this->params['configs']->get('jg_category_view_lightbox_image', 'detail', 'STRING'); +$browse_categories_link = $this->params['configs']->get('jg_gallery_view_browse_categories_link', 1, 'INT'); + +// Import CSS & JS +$wa = $this->document->getWebAssetManager(); +$wa->useStyle('com_joomgallery.site'); +$wa->useStyle('com_joomgallery.jg-icon-font'); + +if ($gallery_class == 'masonry') { + $wa->useScript('com_joomgallery.masonry'); +} + +if ($gallery_class == 'justified') { + $wa->useScript('com_joomgallery.justified'); + $wa->addInlineStyle('.jg-images[class*=" justified-"] .jg-image-caption-hover { right: ' . $justified_gap . 'px; }'); +} + +$lightbox = false; +if ($image_link == 'lightgallery') { + $lightbox = true; + $wa->useScript('com_joomgallery.lightgallery'); + $wa->useScript('com_joomgallery.lg-thumbnail'); + $wa->useStyle('com_joomgallery.lightgallery-bundle'); +} + +// Initialise the grid script +$iniJS = 'window.joomGrid = {'; +$iniJS .= ' itemid: ' . $this->item->id . ','; +$iniJS .= ' pagination: 0,'; +$iniJS .= ' layout: "' . $gallery_class . '",'; +$iniJS .= ' num_columns: ' . $num_columns . ','; +$iniJS .= ' lightbox: ' . ($lightbox ? 'true' : 'false') . ','; +$iniJS .= ' justified: {height: ' . $justified_height . ', gap: ' . $justified_gap . '}'; +$iniJS .= '};'; + +$wa->addInlineScript($iniJS, ['position' => 'before'], [], ['com_joomgallery.joomgrid']); +$wa->useScript('com_joomgallery.joomgrid'); +?> + + diff --git a/src/html/com_joomgallery/gallery/index.html b/src/html/com_joomgallery/gallery/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/html/com_joomgallery/gallery/index.html @@ -0,0 +1 @@ + diff --git a/src/html/com_joomgallery/image/default.php b/src/html/com_joomgallery/image/default.php new file mode 100644 index 0000000..536fcdc --- /dev/null +++ b/src/html/com_joomgallery/image/default.php @@ -0,0 +1,249 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoOnyx.Override + * INGROUP: MokoOnyx + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx + * PATH: /html/com_joomgallery/image/default.php + * VERSION: 01.00.00 + * BRIEF: Image detail view override — single image with metadata, tags, custom fields + */ + +// No direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Session\Session; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Layout\FileLayout; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Fields\Administrator\Helper\FieldsHelper; +use Joomgallery\Component\Joomgallery\Administrator\Helper\JoomHelper; + +// Image params +$image_type = $this->params['configs']->get('jg_detail_view_type_image', 'detail', 'STRING'); +$show_title = $this->params['configs']->get('jg_detail_view_show_title', 0, 'INT'); +$show_category = $this->params['configs']->get('jg_detail_view_show_category', 0, 'INT'); +$show_description = $this->params['configs']->get('jg_detail_view_show_description', 0, 'INT'); +$show_imgdate = $this->params['configs']->get('jg_detail_view_show_imgdate', 0, 'INT'); +$show_imgauthor = $this->params['configs']->get('jg_detail_view_show_imgauthor', 0, 'INT'); +$show_created_by = $this->params['configs']->get('jg_detail_view_show_created_by', 0, 'INT'); +$show_votes = $this->params['configs']->get('jg_detail_view_show_votes', 0, 'INT'); +$show_rating = $this->params['configs']->get('jg_detail_view_show_rating', 0, 'INT'); +$show_hits = $this->params['configs']->get('jg_detail_view_show_hits', 0, 'INT'); +$show_downloads = $this->params['configs']->get('jg_detail_view_show_downloads', 0, 'INT'); +$show_tags = $this->params['configs']->get('jg_detail_view_show_tags', 0, 'INT'); +$show_metadata = $this->params['configs']->get('jg_detail_view_show_metadata', 0, 'INT'); + +// Import CSS & JS +$wa = $this->document->getWebAssetManager(); +$wa->useStyle('com_joomgallery.site'); +$wa->useStyle('com_joomgallery.jg-icon-font'); + +// Access check +$canEdit = $this->getAcl()->checkACL('edit', 'com_joomgallery.image', $this->item->id, $this->item->catid, true); +$canDelete = $this->getAcl()->checkACL('delete', 'com_joomgallery.image', $this->item->id, $this->item->catid, true); +$canCheckin = $this->getAcl()->checkACL('editstate', 'com_joomgallery.image', $this->item->id, $this->item->catid, true) || $this->item->checked_out == Factory::getUser()->id; +$returnURL = base64_encode(JoomHelper::getViewRoute('image', $this->item->id, $this->item->catid, $this->item->language, $this->getLayout())); + +// Tags +$tagLayout = new FileLayout('joomgallery.content.tags'); +$tags = $tagLayout->render($this->item->tags); + +// Metadata +$metadataLayout = new FileLayout('joomgallery.content.metadata'); +$metadata = $metadataLayout->render($this->item->imgmetadata); + +// Custom Fields +$fields = FieldsHelper::getFields('com_joomgallery.image', $this->item); + +// Check if we have any info rows to show +$hasInfo = $show_category || $show_imgdate || $show_imgauthor || $show_created_by + || $show_votes || $show_rating || $show_hits || $show_downloads + || $show_tags || $show_metadata || count($fields) > 0; +?> + +
+ +

escape($this->item->title); ?>

+ + + + + escape($this->item->cattitle); ?> + + + + + + + + +
+
+ <?php echo $this->escape($this->item->title); ?> + item->description)) : ?> +
item->description; ?>
+ +
+ + + +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + get(UserFactoryInterface::class)->loadUserById($this->item->created_by); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0) : ?> + + + + + component->getAccess()->checkViewLevel($field->access) && $field->params->get('display') > 0) : ?> + + params->get('showlabel', true)) : ?> + + + + + + + + + + +
+ + escape($this->item->cattitle); ?> + +
+ +
escape($user->name); ?>
escape($this->item->votes); ?>
escape($this->item->rating); ?>
item->hits; ?>
item->downloads; ?>
escape($field->title); ?>value; ?>
+
+ + + +
diff --git a/src/html/com_joomgallery/image/index.html b/src/html/com_joomgallery/image/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/html/com_joomgallery/image/index.html @@ -0,0 +1 @@ + diff --git a/src/templateDetails.xml b/src/templateDetails.xml index 20b46a5..95f2af0 100644 --- a/src/templateDetails.xml +++ b/src/templateDetails.xml @@ -39,9 +39,9 @@ MokoOnyx - 01.00.18 + 01.00.25 script.php - 2026-04-15 + 2026-04-26 Jonathan Miller || Moko Consulting hello@mokoconsulting.tech (C)GNU General Public License Version 3 - 2026 Moko Consulting @@ -376,3 +376,4 @@ + diff --git a/updates.xml b/updates.xml index 5d2239a..306148f 100644 --- a/updates.xml +++ b/updates.xml @@ -7,6 +7,25 @@ + + MokoOnyx + MokoOnyx development build — unstable. + mokoonyx + template + site + 01.00.26 + 2026-04-26 + https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development + + https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-01.00.26-dev.zip + + + development + Moko Consulting + https://mokoconsulting.tech + + 8.1 + @@ -92,24 +111,4 @@ 8.1 - - MokoOnyx - MokoOnyx (development) - mokoonyx - template - 01.00.26-dev - 2026-04-26 - site - - development - - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx - - https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-01.00.26-dev.zip - - c78a762256fc326662ffa87574246f2f4cbb9009391b93de9ef5eed9f1152107 - - Moko Consulting - https://mokoconsulting.tech -