diff --git a/.github/workflows/static.yml b/.github/workflows/deploy-site.yml similarity index 55% rename from .github/workflows/static.yml rename to .github/workflows/deploy-site.yml index 4396a4133..325cf041a 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/deploy-site.yml @@ -1,5 +1,5 @@ # Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages +name: Deploy site to Pages on: # Runs on pushes targeting the default branch @@ -33,6 +33,8 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + container: + image: registry.gitlab.com/kazam0180/pla-site-tool steps: - name: Checkout uses: actions/checkout@v7 @@ -42,40 +44,48 @@ jobs: uses: actions/cache@v6 with: path: .cache/icons - key: icons-${{ hashFiles('icons/**', 'no-icon.png', 'convert_icons') }} + key: icons-${{ hashFiles('icons/**', 'no-icon.png') }} - name: Cache screenshots id: screenshots-cache uses: actions/cache@v6 with: path: .cache/screenshots - key: screenshots-${{ hashFiles('convert_screenshots') }} + key: screenshots-v1 - - name: Build App Files + - name: Build site + shell: bash run: | - sudo apt install --no-install-recommends wget esbuild -y - wget -O pla-site-tool "$(curl -Ls https://api.github.com/repos/kazam0180/portable-apps/releases | sed 's/[()",{} ]/\n/g' | grep -oi "https.*pla-site-tool.*mage$" | head -1)" - - if [ ! -d .cache/icons ]; then - wget -O magick "https://github.com/ImageMagick/ImageMagick/releases/download/7.1.2-29/ImageMagick-7.1.2-29-gcc-x86_64.AppImage" - chmod a+x magick - ./magick --appimage-extract - ls squashfs-root - sudo cp -r squashfs-root/usr/* /usr/ - sudo chmod a+x /usr/bin/magick - rm -rf magick squashfs-root - else - echo "icon cache hit, skipping ImageMagick download" | tee build-log.txt - fi + set -e + + pla-site-tool > pla-site-tool.log 2>&1 & + PLA_PID=$! + + imgtool -p . -o ./public convert-screenshots > screenshots.log 2>&1 & + SS_PID=$! - [ ! -x ./pla-site-tool ] && chmod a+x ./pla-site-tool - ./pla-site-tool 2>&1 | tee -a build-log.txt - test ${PIPESTATUS[0]} -eq 0 || { echo "pla-site-tool failed, aborting build" | tee -a build-log.txt; exit 1; } + wait "$PLA_PID"; PLA_STATUS=$? + wait "$SS_PID"; SS_STATUS=$? - rm -f sitemap.xml - [ ! -x ./gen_sitemap ] && chmod a+x ./gen_sitemap - ./gen_sitemap public https://portable-linux-apps.github.io/ 2>&1 | tee -a build-log.txt + cat pla-site-tool.log screenshots.log | tee -a build-log.txt + + [ $PLA_STATUS -eq 0 ] || { echo "pla-site-tool failed" | tee -a build-log.txt; exit 1; } + [ $SS_STATUS -eq 0 ] || { echo "imgtool convert-screenshots failed" | tee -a build-log.txt; exit 1; } + + [ ! -x ./minify ] && chmod a+x ./minify + ./minify public 2>&1 | tee -a build-log.txt + + cp -t public/ favicon.ico *.webp *.jpg + cp -r assets/fonts public/assets/ + cp -r casts public/ + cp -r am-gui public/ + cp -t public/ google*.html BingSiteAuth.xml + + echo "site generated" | tee -a build-log.txt + - name: Process icons + run: | + set -e if [ -d .cache/icons ]; then mkdir -p public/icons public/icons_48 cp -r .cache/icons/public/icons/. public/icons/ @@ -84,8 +94,7 @@ jobs: cp .cache/icons/public/no-icon_48.webp public/no-icon_48.webp echo "icons restored from cache" | tee -a build-log.txt else - [ ! -x ./convert_icons ] && chmod a+x ./convert_icons - ./convert_icons public 2>&1 | tee -a build-log.txt + imgtool -p . -o ./public convert-icons 2>&1 | tee -a build-log.txt mkdir -p .cache/icons/public cp -r public/icons .cache/icons/public/ cp -r public/icons_48 .cache/icons/public/ @@ -93,29 +102,6 @@ jobs: cp public/no-icon_48.webp .cache/icons/public/ fi - if ! command -v magick >/dev/null 2>&1; then - wget -O magick "https://github.com/ImageMagick/ImageMagick/releases/download/7.1.2-29/ImageMagick-7.1.2-29-gcc-x86_64.AppImage" - chmod a+x magick - ./magick --appimage-extract - sudo cp -r squashfs-root/usr/* /usr/ - sudo chmod a+x /usr/bin/magick - rm -rf magick squashfs-root - fi - - [ ! -x ./convert_screenshots ] && chmod a+x ./convert_screenshots - ./convert_screenshots public 2>&1 | tee -a build-log.txt - - [ ! -x ./minify ] && chmod a+x ./minify - ./minify public 2>&1 | tee -a build-log.txt - - cp -t public/ favicon.ico *.webp *.jpg - cp -r assets/fonts public/assets/ - cp -r casts public/ - cp -r am-gui public/ - cp -t public/ sitemap.xml google*.html BingSiteAuth.xml - - echo "site generated" | tee -a build-log.txt - - name: Upload Build Log uses: actions/upload-artifact@v7 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe620a7ab..af6b2a8c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,7 @@ The site supports multiple languages using gettext `.po` files. Translatable str **Directory structure:** ``` locales/ +├── pla-site.pot # Translation template ├── en/ │ └── pla-site.po # English translations (source language) ├── it/ @@ -122,7 +123,7 @@ The [static.yml](.github/workflows/static.yml) workflow builds and deploys the s ```sh # download the generator -wget -O pla-site-tool "https://github.com/kazam0180/portable-apps/releases/download/pla-site-tool-0.1.3/pla-site-tool_0.1.3-x86_64.AppImage" +wget -O pla-site-tool "https://github.com/kazam0180/portable-apps/releases/download/pla-site-tool-0.2.3/pla-site-tool_0.2.3-x86_64.AppImage" chmod +x pla-site-tool ./pla-site-tool # generates the site into public/ diff --git a/app_page.in b/app_page.in index 154ab581f..b89d226bc 100644 --- a/app_page.in +++ b/app_page.in @@ -18,12 +18,12 @@ - - + + - - - + + + diff --git a/apps.in b/apps.in index daf55d8a4..5c6c05692 100644 --- a/apps.in +++ b/apps.in @@ -1,5 +1,5 @@ - + @@ -15,11 +15,11 @@ - - + + - - + + @@ -46,10 +46,8 @@ +
diff --git a/assets/css/style.css b/assets/css/style.css index 8a35af4ef..2f0355389 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -192,6 +192,14 @@ nav a:focus { background: var(--color-surface); } +/* === Results Count === */ +.results-count { + color: var(--color-text-muted); + font-size: 0.875rem; + margin-left: 0.5rem; + white-space: nowrap; +} + @media (max-width: 640px) { .lang-selector { width: 100%; diff --git a/assets/js/script.js b/assets/js/script.js index 9bbd06edd..f934a1161 100644 --- a/assets/js/script.js +++ b/assets/js/script.js @@ -131,6 +131,9 @@ document.addEventListener('DOMContentLoaded', function() { if (visible) matched.push({ index: k, rank: terms.length ? matchRank(k, terms) : 0 }); } + var countEl = document.getElementById('results-count'); + if (countEl) countEl.textContent = matched.length + ' results'; + if (terms.length) { matched.sort(function(a, b) { return a.rank !== b.rank ? a.rank - b.rank : cache[a.index].name.localeCompare(cache[b.index].name); diff --git a/cat_page.in b/cat_page.in index 71f3e356f..e1cf7944a 100644 --- a/cat_page.in +++ b/cat_page.in @@ -1,5 +1,5 @@ - + @@ -17,11 +17,11 @@ - - + + - - + + @@ -48,10 +48,8 @@ +
diff --git a/convert_screenshots b/convert_screenshots deleted file mode 100644 index 263987de3..000000000 --- a/convert_screenshots +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 - -import hashlib -import json -import os -import re -import shutil -import subprocess -import sys -import threading -import urllib.error -import urllib.request -from concurrent.futures import ThreadPoolExecutor -from urllib.parse import urlparse - -SITE_DIR = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SITE_DIR") -if not SITE_DIR: - print("Usage: convert_screenshots [repo_dir]", file=sys.stderr) - sys.exit(1) -REPO_DIR = sys.argv[2] if len(sys.argv) > 2 else os.environ.get("REPO_DIR", ".") -QUALITY = os.environ.get("WEBP_QUALITY", "100") - -APPS_DIR = os.path.join(REPO_DIR, "apps") -CACHE_DIR = os.path.join(REPO_DIR, ".cache", "screenshots") -DL_DIR = os.path.join(CACHE_DIR, "downloads") -WEBP_DIR = os.path.join(CACHE_DIR, "webp") -MANIFEST = os.path.join(CACHE_DIR, "manifest.json") -OUT_DIR = os.path.join(SITE_DIR, "screenshots") - -MAX_BYTES = 64 * 1024 * 1024 -USER_AGENT = "Mozilla/5.0 (convert_screenshots; +https://portable-linux-apps.github.io)" -EXTS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpeg", ".gif": "gif", ".bmp": "bmp", ".webp": "webp"} - - -def magick_cmd(): - for cmd in ("magick", "magick-im7.q16"): - path = shutil.which(cmd) - if path: - return path - print("Error: magick not found (install it with: sudo apt install imagemagick)", file=sys.stderr) - sys.exit(1) - - -MAGICK = magick_cmd() - -LOG_LOCK = threading.Lock() - - -def log(msg): - with LOG_LOCK: - print(msg, file=sys.stderr, flush=True) - - -def stable_key(url): - return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] - - -def collect_urls(): - urls = {} - for fname in sorted(os.listdir(APPS_DIR)): - if fname.startswith(".") or fname.endswith("~"): - continue - path = os.path.join(APPS_DIR, fname) - if not os.path.isfile(path): - continue - try: - with open(path, "r", errors="replace") as fh: - for line in fh: - m = re.match(r"^#\s*SCREENSHOTS\s*:\s*(.+?)\s*$", line) - if not m: - continue - for token in m.group(1).split(): - if not token.startswith(("http://", "https://")): - continue - path_only = token.split("?", 1)[0].split("#", 1)[0] - if path_only.lower().endswith(".webp"): - continue - urls[token] = fname - except OSError as e: - print(f"warning: cannot read {path}: {e}") - return urls - - -def ext_for(url): - path_only = url.split("?", 1)[0].split("#", 1)[0] - ext = os.path.splitext(urlparse(path_only).path)[1].lower() - ext = ext.split("?")[0] - if ext in EXTS and ext != ".webp": - return EXTS[ext] - return "img" - - -def read_manifest(): - if os.path.exists(MANIFEST): - try: - with open(MANIFEST, "r") as fh: - return json.load(fh) - except (OSError, ValueError): - pass - return {} - - -def write_manifest(manifest): - os.makedirs(CACHE_DIR, exist_ok=True) - tmp = MANIFEST + ".tmp" - with open(tmp, "w") as fh: - json.dump(manifest, fh, indent=2, sort_keys=True) - os.rename(tmp, MANIFEST) - - -def fetch(url, etag=None): - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - if etag: - req.add_header("If-None-Match", etag) - try: - with urllib.request.urlopen(req, timeout=60) as resp: - if resp.status == 304: - return "unchanged", "", {} - headers = dict(resp.headers.items()) - buf = bytearray() - while True: - chunk = resp.read(65536) - if not chunk: - break - buf.extend(chunk) - if len(buf) > MAX_BYTES: - return "error", "", {} - return "ok", bytes(buf), headers - except urllib.error.HTTPError as e: - if e.code == 304: - return "unchanged", "", {} - if e.code == 404: - return "missing", "", {} - return "error", "", {} - except OSError: - return "error", "", "" - - -def is_image_body(body): - return (body[:8] == b"\x89PNG\r\n\x1a\n" - or body[:3] == b"\xff\xd8\xff" - or body[:6] in (b"GIF87a", b"GIF89a") - or (body[:4] == b"RIFF" and body[8:12] == b"WEBP")) - - -def convert_to_webp(src_path, dst_path): - tmp = dst_path + ".tmp" - try: - os.makedirs(os.path.dirname(dst_path), exist_ok=True) - subprocess.run([MAGICK, "-quality", QUALITY, src_path, tmp], - check=True, capture_output=True, timeout=120) - os.rename(tmp, dst_path) - return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): - if os.path.exists(tmp): - os.unlink(tmp) - return False - - -def process_url(url, app, manifest, stats): - log(f"[{app}] processing {url}") - key = stable_key(url) - entry = manifest.get(url) - dl_file = os.path.join(DL_DIR, f"{key}.{ext_for(url)}") - webp_file = os.path.join(WEBP_DIR, f"{key}.webp") - - if entry and os.path.exists(webp_file): - if entry.get("etag"): - status, body, headers = fetch(url, etag=entry["etag"]) - if status == "unchanged": - stats["unchanged"] += 1 - return key - if status == "missing": - stats["missing"] += 1 - manifest.pop(url, None) - return None - if status != "ok": - stats["changed"] += 1 - manifest.pop(url, None) - return None - body = None - else: - stats["unchanged"] += 1 - return key - else: - status, body, headers = fetch(url) - if status == "missing": - stats["missing"] += 1 - return None - if status != "ok": - stats["failed"] += 1 - return None - manifest.pop(url, None) - stats["downloaded"] += 1 - if not is_image_body(body): - stats["failed"] += 1 - return None - - sha = hashlib.sha256(body if body is not None else b"").hexdigest() - if body is not None: - os.makedirs(DL_DIR, exist_ok=True) - tmp = dl_file + ".tmp" - with open(tmp, "wb") as fh: - fh.write(body) - os.rename(tmp, dl_file) - - if body is not None and body[:12] == b"RIFF" and b"WEBP" in body[:16]: - shutil.copy2(dl_file, webp_file) - else: - if not convert_to_webp(dl_file, webp_file): - stats["failed"] += 1 - return None - stats["converted"] += 1 - - manifest[url] = { - "file": os.path.basename(dl_file), - "webp": f"{key}.webp", - "sha256": sha, - "etag": headers.get("ETag", headers.get("etag")) if isinstance(headers, dict) else None, - } - return key - - -def replace_in_file(path, mapping, rel_prefix): - try: - with open(path, "r", encoding="utf-8", errors="replace") as fh: - content = fh.read() - except OSError: - return 0 - count = 0 - for url, key in mapping.items(): - new = f"/screenshots/{key}.webp" - n = content.count(url) - if n: - content = content.replace(url, new) - count += n - if count: - tmp = path + ".tmp" - with open(tmp, "w", encoding="utf-8") as fh: - fh.write(content) - os.rename(tmp, path) - return count - - -def main(): - urls = collect_urls() - if not urls: - print("no screenshot URLs found in app files") - return - - manifest = read_manifest() - if os.path.exists(MANIFEST): - log(f"cache: found ({len(manifest)} entries)") - else: - log("cache: not found (fresh run)") - stats = {"downloaded": 0, "converted": 0, "unchanged": 0, - "missing": 0, "failed": 0, "changed": 0} - results = {} - - with ThreadPoolExecutor(max_workers=8) as pool: - futures = {pool.submit(process_url, url, urls[url], manifest, stats): url for url in urls} - for fut in futures: - url = futures[fut] - key = fut.result() - if key: - results[url] = key - - for url in list(manifest): - if url not in urls: - del manifest[url] - - write_manifest(manifest) - - if os.path.isdir(OUT_DIR): - shutil.rmtree(OUT_DIR) - os.makedirs(OUT_DIR, exist_ok=True) - os.makedirs(WEBP_DIR, exist_ok=True) - copied = 0 - if os.path.isdir(WEBP_DIR): - for webp in os.listdir(WEBP_DIR): - if webp.endswith(".webp") and os.path.isfile(os.path.join(WEBP_DIR, webp)): - shutil.copy2(os.path.join(WEBP_DIR, webp), os.path.join(OUT_DIR, webp)) - copied += 1 - - replaced_total = 0 - for root, dirs, files in os.walk(SITE_DIR): - dirs[:] = [d for d in dirs if d not in (".cache",)] - for fname in files: - if not fname.endswith((".json", ".html")): - continue - replaced_total += replace_in_file(os.path.join(root, fname), results) - - print(f"screenshots: {stats['downloaded']} downloaded, {stats['converted']} converted, " - f"{stats['unchanged']} unchanged (cache), {stats['missing']} not found, " - f"{stats['failed']} failed, {copied} webp in {OUT_DIR}, " - f"{replaced_total} URL references replaced") - - -if __name__ == "__main__": - main() diff --git a/index.in b/index.in index 8ec2b668c..51f02617b 100644 --- a/index.in +++ b/index.in @@ -1,5 +1,5 @@ - + @@ -17,11 +17,11 @@ - - - - - + + + + + @@ -59,9 +59,6 @@