#!/bin/bash # LibrePortal WebUI Config Generator # Generates apps.json from container configuration files # Single awk pass extracting SERVICE_TAG_1..SERVICE_TAG_9 from a compose # file. Was 9 separate tagsManagerGetTagContent forks per app — across # 31 apps that's 279 awk forks; now 1 per app. # Output: tab-separated values for slots 1..9 (empty for missing). _webuiReadServiceTags() { local file_path="$1" awk ' BEGIN { for (i = 1; i <= 9; i++) v[i] = "" } { for (i = 1; i <= 9; i++) { if (v[i] != "") continue marker = "#LIBREPORTAL|SERVICE_TAG_" i "|" p = index($0, marker) if (p == 0) continue rest = substr($0, p + length(marker)) n = match(rest, /[| \t\r]/) if (n > 0) rest = substr(rest, 1, n - 1) v[i] = rest } } END { for (i = 1; i <= 9; i++) printf "%s\t", v[i] print "" } ' "$file_path" } webuiGenerateLibrePortalConfig() { local testing_mode="$1" local specific_app="$2" local output_file="${containers_dir}libreportal/frontend/data/apps/generated/apps.json" local first_app=true local app_count=0 isNotice "Generating LibrePortal apps.json from config files..." [[ -n "$specific_app" ]] && isNotice "Filtering to specific app: $specific_app" runFileOp mkdir -p "$(dirname "$output_file")" local temp_file="$(mktemp)" cat > "$temp_file" << 'EOF' { "apps": [ EOF # Determine which apps to process local apps_to_process=() if [[ -n "$specific_app" ]]; then if [[ -d "$install_containers_dir/$specific_app" && "$specific_app" != "template" ]]; then apps_to_process=("$specific_app") else isError "App '$specific_app' not found or is template" fi else while IFS= read -r -d '' dir; do app_name="${dir##*/}" [[ "$app_name" != "template" ]] && apps_to_process+=("$app_name") done < <(find "$install_containers_dir" -maxdepth 1 -type d ! -path "$install_containers_dir" -print0 | sort -z) fi local app_name dir deployed_config_file config_file local title description long_description category url config_vars local host_install host_package local var_name var_value local APP_UPPER for app_name in "${apps_to_process[@]}"; do dir="$install_containers_dir/$app_name" deployed_config_file="$containers_dir/$app_name/$app_name.config" config_file="$dir/$app_name.config" [[ -f "$deployed_config_file" ]] && config_file="$deployed_config_file" [[ ! -f "$config_file" ]] && { isNotice "No config file for $app_name"; continue; } title=""; description=""; long_description=""; category=""; url=""; config_vars="" host_install=""; host_package="" APP_UPPER="${app_name^^}" # Single pass through the config file. while IFS='=' read -r var_name var_value || [[ -n "$var_name" ]]; do [[ -z "$var_name" || "$var_name" =~ ^[[:space:]]*# ]] && continue [[ "$var_name" == CFG_${APP_UPPER}_* ]] || continue # Strip surrounding quotes + \r, then trim whitespace. var_value="${var_value#\"}" var_value="${var_value%\"}" var_value="${var_value//$'\r'/}" var_value="${var_value#"${var_value%%[![:space:]]*}"}" var_value="${var_value%"${var_value##*[![:space:]]}"}" case "$var_name" in "CFG_${APP_UPPER}_TITLE") title="$var_value" ;; "CFG_${APP_UPPER}_DESCRIPTION") description="$var_value" ;; "CFG_${APP_UPPER}_LONG_DESCRIPTION") long_description="$var_value" ;; "CFG_${APP_UPPER}_CATEGORY") category="$var_value" ;; "CFG_${APP_UPPER}_URL") url="$var_value" config_vars+="\"$var_name\": \"$var_value\"," ;; "CFG_${APP_UPPER}_HOST_INSTALL") host_install="$var_value" config_vars+="\"$var_name\": \"$var_value\"," ;; "CFG_${APP_UPPER}_HOST_PACKAGE") host_package="$var_value" config_vars+="\"$var_name\": \"$var_value\"," ;; *) config_vars+="\"$var_name\": \"$var_value\"," ;; esac done < "$config_file" [[ -z "$title" || -z "$category" ]] && { isNotice "Skipping $app_name - missing essential data"; continue; } local is_installed="false" if [[ "$host_install" == "true" ]]; then if [[ -n "$host_package" ]] \ && dpkg-query -W -f='${Status}' "$host_package" 2>/dev/null | grep -q "install ok installed"; then is_installed="true" fi else [[ -d "$containers_dir/$app_name" ]] && is_installed="true" fi # Categories: comma-list, lowercase + trimmed via bash param expansion # (was echo|tr|sed — 3 forks per category per app). local mapped_category="" mapped_categories_json="" if [[ -n "$category" ]]; then local _first_cat="" _cats_acc="" local _cat_arr _c IFS=',' read -ra _cat_arr <<< "$category" for _c in "${_cat_arr[@]}"; do _c="${_c,,}" _c="${_c#"${_c%%[![:space:]]*}"}" _c="${_c%"${_c##*[![:space:]]}"}" [[ -z "$_c" ]] && continue [[ -z "$_first_cat" ]] && _first_cat="$_c" [[ -n "$_cats_acc" ]] && _cats_acc+="," _cats_acc+="\"$_c\"" done mapped_category="$_first_cat" mapped_categories_json="[$_cats_acc]" fi local full_name="$title" [[ -n "$description" ]] && full_name="$title - $description" full_name="${full_name//\\/\\\\}" full_name="${full_name//\"/\\\"}" long_description="${long_description//\\/\\\\}" long_description="${long_description//\"/\\\"}" local icon_file="" if [[ -f "$dir/$app_name.svg" ]]; then icon_file="$app_name.svg" elif [[ -f "$dir/$app_name.png" ]]; then icon_file="$app_name.png" fi if [[ -n "$icon_file" ]]; then local icons_apps_dir="${containers_dir}libreportal/frontend/core/icons/apps" runFileOp mkdir -p "$icons_apps_dir" runFileWrite "$icons_apps_dir/$icon_file" < "$dir/$icon_file" 2>/dev/null fi # Service names — one awk pass instead of 9 grep|awk|head pipelines. local services_json="" local compose_file="$dir/docker-compose.yml" if [[ -f "$compose_file" ]]; then local _tags_line _tags_line=$(_webuiReadServiceTags "$compose_file") local _svc_arr _s IFS=$'\t' read -ra _svc_arr <<< "$_tags_line" for _s in "${_svc_arr[@]}"; do [[ -n "$_s" ]] && services_json+="\"$_s\"," done services_json="${services_json%,}" fi # Live-backup capability: the app can be snapshotted live without # downtime only if it declares a dumpable database, or is explicitly # marked live-safe. The app config UI uses this to hide the "live" # strategy option where it would be unsafe. local backup_live_capable="false" if [[ -f "$compose_file" ]]; then if grep -qE '^[[:space:]]*libreportal\.backup\.(db|files)[[:space:]]*:' "$compose_file" 2>/dev/null \ || grep -qE '^[[:space:]]*libreportal\.backup\.live[[:space:]]*:[[:space:]]*["'\'']?true' "$compose_file" 2>/dev/null; then backup_live_capable="true" fi fi if [[ "$first_app" == false ]]; then echo " }," >> "$temp_file" fi first_app=false app_count=$((app_count + 1)) # Build the app's field list, then emit with correct commas in one # pass. Eliminates the per-app `sed -i '$ s/,$//'` (31 forks across # the catalog) that was needed to undo a speculative trailing # comma when longDescription was absent. local fields=() fields+=(" \"name\": \"$full_name\"") fields+=(" \"command\": \"libreportal app install $app_name\"") fields+=(" \"category\": \"$mapped_category\"") [[ -n "$mapped_categories_json" ]] && fields+=(" \"categories\": $mapped_categories_json") fields+=(" \"installed\": $is_installed") fields+=(" \"backup_live_capable\": $backup_live_capable") [[ -n "$url" ]] && fields+=(" \"url\": \"$url\"") [[ -n "$description" ]] && fields+=(" \"description\": \"$description\"") [[ -n "$icon_file" ]] && fields+=(" \"icon\": \"/core/icons/apps/$icon_file\"") [[ -n "$services_json" ]] && fields+=(" \"services\": [$services_json]") if [[ -n "$config_vars" ]]; then fields+=(" \"config\": {"$'\n'"${config_vars%,}"$'\n'" }") fi [[ -n "$long_description" ]] && fields+=(" \"longDescription\": \"$long_description\"") { echo " {" local _n=${#fields[@]} local _i for _i in "${!fields[@]}"; do if [[ $_i -lt $((_n - 1)) ]]; then echo "${fields[$_i]}," else echo "${fields[$_i]}" fi done } >> "$temp_file" done echo " }" >> "$temp_file" echo " ]" >> "$temp_file" echo "}" >> "$temp_file" # Already root via start.sh — drop redundant sudo. if [ $? -eq 0 ]; then runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file" else rm -f "$temp_file" 2>/dev/null fi if [[ "$testing_mode" == "test" ]]; then isSuccessful "Generated apps.json (test mode, $app_count apps)" else isSuccessful "Generated apps.json ($app_count apps)" fi }