#!/bin/sh

################################################################****
#  BLOCK 01 - PATHS, DEFAULTS AND SMALL ENCODING HELPERS
################################################################****
APP_DIR=/mnt/onboard/.adds/kobo-opds
CONF_FILE="$APP_DIR/config/catalog.conf"
CATALOG_DIR="$APP_DIR/config/catalogs"
ACTIVE_FILE="$APP_DIR/config/active_catalog"
RUN_DIR="$APP_DIR/run"
CACHE_FILE="$RUN_DIR/feed.xml"
CACHE_URL_FILE="$RUN_DIR/feed.url"
CACHE_TIME_FILE="$RUN_DIR/feed.time"
CACHE_TTL=60
FETCH_LOG="$RUN_DIR/fetch.log"
LAST_ERROR_FILE="$RUN_DIR/last_error"
APP_VERSION="0.9.15"
UPDATE_MANIFEST_URL="https://effecta.duckdns.org/files/koboopds/update.json"
UPDATE_LAST_CHECK_FILE="$APP_DIR/config/update_last_check"
UPDATE_STATE_FILE="$RUN_DIR/update_available"
UPDATE_MANIFEST_FILE="$RUN_DIR/update.json"
UI_CONF="$APP_DIR/config/ui.conf"
DOWNLOAD_CONF="$APP_DIR/config/download.conf"
REPLACE_EXISTING=$(sed -n 's/^REPLACE_EXISTING=//p' "$DOWNLOAD_CONF" 2>/dev/null | head -n 1)
[ "$REPLACE_EXISTING" = 1 ] || REPLACE_EXISTING=0
PREFERRED_FORMAT=$(sed -n 's/^PREFERRED_FORMAT=//p' "$DOWNLOAD_CONF" 2>/dev/null | head -n 1)
case "$PREFERRED_FORMAT" in kepub|epub|pdf|cbz|cbr) ;; *) PREFERRED_FORMAT=epub ;; esac
UI_LANGUAGE=$(sed -n 's/^UI_LANGUAGE=//p' "$UI_CONF" 2>/dev/null | head -n 1)
[ -n "$UI_LANGUAGE" ] || UI_LANGUAGE=auto
if [ "$UI_LANGUAGE" = auto ]; then
    case "$HTTP_ACCEPT_LANGUAGE" in
        fr*) UI_LANG=fr ;; de*) UI_LANG=de ;; es*) UI_LANG=es ;; en*) UI_LANG=en ;; *) UI_LANG=it ;;
    esac
else
    UI_LANG=$UI_LANGUAGE
fi
. "$APP_DIR/bin/i18n.sh"
CONFIGURED=0
CATALOG_NAME="Project Gutenberg"
CATALOG_URL=""
USERNAME=""
PASSWORD=""

url_decode() {
    escaped=$(printf '%s' "$1" | sed 's/+/ /g;s/%/\\x/g')
    printf '%b' "$escaped"
}

url_encode() {
    # od pads its output with spaces; discard the trailing separator so it
    # cannot become an invalid standalone percent sign in the URL.
    printf '%s' "$1" | od -An -tx1 | tr -d '\n' | sed 's/  */%/g;s/%$//'
}

html_escape() {
    sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g;s/"/\&quot;/g;s/'"'"'/\&#39;/g'
}

query_value() {
    key=$1
    data=$2
    raw=""
    old_ifs=$IFS
    IFS='&'
    set -f
    for field in $data; do
        case "$field" in
            "$key="*) raw=${field#*=}; break ;;
        esac
    done
    set +f
    IFS=$old_ifs
    url_decode "$raw"
}

read_config_value() {
    key=$1
    sed -n "s/^${key}=//p" "$CONF_FILE" | head -n 1
}

valid_catalog_id() {
    case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac
}

invalidate_feed_cache() {
    # Catalog content must never cross source or credential changes.
    rm -f "$CACHE_FILE" "$CACHE_FILE.tmp" "$CACHE_URL_FILE" "$CACHE_URL_FILE.tmp" \
        "$CACHE_TIME_FILE" "$CACHE_TIME_FILE.tmp"
}

selected_language_attribute() {
    [ "$UI_LANGUAGE" = "$1" ] && printf ' selected'
}

version_is_newer() {
    awk -v available="$1" -v installed="$2" 'BEGIN {
        split(available, a, "."); split(installed, b, ".")
        for (i = 1; i <= 3; i++) {
            av = a[i] + 0; bv = b[i] + 0
            if (av > bv) exit 0
            if (av < bv) exit 1
        }
        exit 1
    }'
}

manifest_value() {
    key=$1
    file=$2
    sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n 1
}

load_update_state() {
    UPDATE_AVAILABLE_VERSION=""
    UPDATE_PACKAGE_URL=""
    UPDATE_PACKAGE_SHA256=""
    UPDATE_NOTES=""
    [ -s "$UPDATE_STATE_FILE" ] || return 0
    UPDATE_AVAILABLE_VERSION=$(sed -n '1p' "$UPDATE_STATE_FILE")
    UPDATE_PACKAGE_URL=$(sed -n '2p' "$UPDATE_STATE_FILE")
    UPDATE_PACKAGE_SHA256=$(sed -n '3p' "$UPDATE_STATE_FILE")
    UPDATE_NOTES=$(sed -n '4p' "$UPDATE_STATE_FILE")
    version_is_newer "$UPDATE_AVAILABLE_VERSION" "$APP_VERSION" || {
        rm -f "$UPDATE_STATE_FILE"
        UPDATE_AVAILABLE_VERSION=""
    }
}

check_for_update() {
    force_check=$1
    today=$(date +%Y-%m-%d)
    last_check=$(sed -n '1p' "$UPDATE_LAST_CHECK_FILE" 2>/dev/null)
    [ "$force_check" = 1 ] || [ "$last_check" != "$today" ] || return 0
    mkdir -p "$RUN_DIR"
    if wget -q -T 8 -O "$UPDATE_MANIFEST_FILE.tmp" "$UPDATE_MANIFEST_URL"; then
        mv "$UPDATE_MANIFEST_FILE.tmp" "$UPDATE_MANIFEST_FILE"
        umask 077
        printf '%s\n' "$today" > "$UPDATE_LAST_CHECK_FILE.tmp"
        mv "$UPDATE_LAST_CHECK_FILE.tmp" "$UPDATE_LAST_CHECK_FILE"
        available_version=$(manifest_value version "$UPDATE_MANIFEST_FILE")
        package_url=$(manifest_value package_url "$UPDATE_MANIFEST_FILE")
        package_sha256=$(manifest_value sha256 "$UPDATE_MANIFEST_FILE")
        release_notes=$(manifest_value notes "$UPDATE_MANIFEST_FILE")
        case "$package_url" in https://effecta.duckdns.org/*) ;; *) package_url="" ;; esac
        case "$package_sha256" in [0-9a-fA-F][0-9a-fA-F]*) ;; *) package_sha256="" ;; esac
        if [ -n "$package_url" ] && [ ${#package_sha256} -eq 64 ] && version_is_newer "$available_version" "$APP_VERSION"; then
            {
                printf '%s\n' "$available_version"
                printf '%s\n' "$package_url"
                printf '%s\n' "$package_sha256"
                printf '%s\n' "$release_notes"
            } > "$UPDATE_STATE_FILE.tmp"
            mv "$UPDATE_STATE_FILE.tmp" "$UPDATE_STATE_FILE"
        else
            rm -f "$UPDATE_STATE_FILE"
        fi
    else
        rm -f "$UPDATE_MANIFEST_FILE.tmp"
    fi
    load_update_state
}

load_catalog() {
    CONFIGURED=0 CATALOG_NAME="Project Gutenberg" CATALOG_URL="" USERNAME="" PASSWORD=""
    [ -f "$CONF_FILE" ] || return 0
    CONFIGURED=$(read_config_value CONFIGURED)
    CATALOG_NAME=$(read_config_value CATALOG_NAME)
    CATALOG_URL=$(read_config_value CATALOG_URL)
    USERNAME=$(read_config_value USERNAME)
    PASSWORD=$(read_config_value PASSWORD)
}

resolve_url() {
    link=$(printf '%s' "$1" | sed 's/&amp;/\&/g;s/&quot;/"/g')
    base=$2
    case "$link" in
        http://*|https://*) printf '%s' "$link" ;;
        /*)
            origin=$(printf '%s' "$base" | sed 's|\(https\{0,1\}://[^/]*\).*|\1|')
            printf '%s%s' "$origin" "$link"
            ;;
        *)
            directory=$(printf '%s' "$base" | sed 's|[^/]*$||')
            printf '%s%s' "$directory" "$link"
            ;;
    esac
}

wget_authenticated() {
    # Kobo BusyBox wget does not consistently implement GNU-style
    # --user/--password options, but it accepts explicit HTTP headers.
    # Gutenberg requires OPDS clients to identify themselves with a
    # contactable User-Agent and may block anonymous automated requests.
    user_agent="KoboOPDS/$APP_VERSION (+https://effecta.it)"
    if [ -z "$USERNAME" ] && [ -z "$PASSWORD" ]; then
        wget --header="User-Agent: $user_agent" --header="Accept: application/atom+xml, application/xml;q=0.9, */*;q=0.1" "$@"
    else
        auth_token=$(printf '%s:%s' "$USERNAME" "$PASSWORD" | busybox base64 | tr -d '\r\n') || return 1
        wget --header="User-Agent: $user_agent" --header="Accept: application/atom+xml, application/xml;q=0.9, */*;q=0.1" --header="Authorization: Basic $auth_token" "$@"
    fi
}

discover_search_template() {
    # Cache one OpenSearch template per catalog URL. Discovery is repeated
    # only when the source address changes or the runtime cache is removed.
    search_key=$(printf '%s' "$CATALOG_URL" | cksum | awk '{print $1}')
    search_cache="$RUN_DIR/search-template-$search_key"
    if [ -s "$search_cache" ]; then
        sed -n '1p' "$search_cache"
        return 0
    fi

    mkdir -p "$RUN_DIR"
    discovery_feed="$CACHE_FILE"
    if [ ! -s "$discovery_feed" ] || ! tr '\n\r' '  ' < "$discovery_feed" | grep -q 'rel="search"'; then
        discovery_feed="$RUN_DIR/search-discovery.xml"
        if ! wget_authenticated -q -T 25 -O "$discovery_feed.tmp" "$CATALOG_URL" 2> "$FETCH_LOG"; then
            rm -f "$discovery_feed.tmp"
            return 1
        fi
        mv "$discovery_feed.tmp" "$discovery_feed"
    fi

    search_tag=$(tr '\n\r' '  ' < "$discovery_feed" | sed 's|<link|\n<link|g' | grep 'rel="search"' | head -n 1)
    search_href=$(printf '%s' "$search_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
    [ -n "$search_href" ] || return 1
    search_href=$(resolve_url "$search_href" "$CATALOG_URL")

    case "$search_href" in
        *'{searchTerms}'*) search_template=$search_href ;;
        *)
            search_description="$RUN_DIR/search-description-$search_key.xml"
            if ! wget_authenticated -q -T 25 -O "$search_description.tmp" "$search_href" 2> "$FETCH_LOG"; then
                rm -f "$search_description.tmp"
                return 1
            fi
            mv "$search_description.tmp" "$search_description"
            search_url_tag=$(tr '\n\r' '  ' < "$search_description" | sed 's|<Url|\n<Url|g' | grep 'type="application/atom+xml' | head -n 1)
            search_template=$(printf '%s' "$search_url_tag" | sed -n 's|.*template="\([^"]*\)".*|\1|p')
            search_template=$(resolve_url "$search_template" "$search_href")
            ;;
    esac

    case "$search_template" in
        http://*'{searchTerms}'*|https://*'{searchTerms}'*) ;;
        *) return 1 ;;
    esac
    umask 077
    printf '%s\n' "$search_template" > "$search_cache.tmp"
    mv "$search_cache.tmp" "$search_cache"
    printf '%s' "$search_template"
}

build_search_url() {
    template=$1
    encoded_terms=$(url_encode "$2")
    # Resolve the required search term and conservative defaults for common
    # optional OpenSearch parameters. Unknown optional values become empty.
    printf '%s' "$template" | sed \
        -e "s|{searchTerms}|$encoded_terms|g" \
        -e 's|{startIndex?}|1|g' \
        -e 's|{startPage?}|1|g' \
        -e 's|{count?}||g' \
        -e 's|{language?}||g' \
        -e 's|{inputEncoding?}|UTF-8|g' \
        -e 's|{outputEncoding?}|UTF-8|g' \
        -e 's|{[^}]*?}||g'
}

classify_fetch_error() {
    if grep -qi '502\|503\|504\|service unavailable\|gateway.*timeout' "$FETCH_LOG"; then
        printf 'SERVER_BUSY'
    elif grep -qi '401\|unauthorized' "$FETCH_LOG"; then
        printf 'AUTH'
    elif grep -qi '403\|forbidden' "$FETCH_LOG"; then
        printf 'FORBIDDEN'
    elif grep -qi '404\|not found' "$FETCH_LOG"; then
        printf 'NOT_FOUND'
    elif grep -qi 'certificate\|TLS\|SSL' "$FETCH_LOG"; then
        printf 'TLS'
    elif grep -qi 'resolve\|bad address\|name.*known' "$FETCH_LOG"; then
        printf 'DNS'
    elif grep -qi 'timed out\|timeout' "$FETCH_LOG"; then
        printf 'TIMEOUT'
    elif grep -qi 'no space left' "$FETCH_LOG"; then
        printf 'DISK_FULL'
    else
        printf 'NETWORK'
    fi
}

error_message() {
    case "$1" in
        SERVER_BUSY) printf 'The OPDS server is temporarily unavailable. Please try again later.' ;;
        AUTH) printf 'Invalid username or password.' ;;
        FORBIDDEN) printf 'The server denied access to this catalog.' ;;
        NOT_FOUND) printf 'Catalog or resource not found (HTTP 404).' ;;
        TLS) printf 'HTTPS failed: incompatible certificate or TLS protocol.' ;;
        DNS) printf 'Server name cannot be resolved. Check the address and Wi-Fi.' ;;
        TIMEOUT) printf 'The server did not respond before the timeout.' ;;
        DISK_FULL) printf 'Not enough free space on the Kobo.' ;;
        *) printf 'Could not connect to the catalog.' ;;
    esac
}

safe_download_name() {
    cleaned=$(printf '%s' "$1" | tr -d '\r\n' | sed 's|[/\\:*?"<>|]|_|g;s/^[. ]*//;s/[. ]*$//')
    [ -n "$cleaned" ] || cleaned=book.epub
    printf '%s' "$cleaned"
}

download_to_kobo() {
    source_url=$1
    source_name=$(safe_download_name "$2")
    case "$source_url" in http://*|https://*) ;; *) return 2 ;; esac
    mkdir -p "$DOWNLOAD_DIR" "$RUN_DIR"
    destination="$DOWNLOAD_DIR/$source_name"
    [ -e "$destination" ] && [ "$REPLACE_EXISTING" != 1 ] && return 3
    partial="$destination.part"
    rm -f "$partial"
    if ! wget_authenticated -S -T 60 -O "$partial" "$source_url" 2> "$FETCH_LOG"; then
        rm -f "$partial"
        return 1
    fi
    mv "$partial" "$destination"
    sync
    # Metadata is queued separately and applied only after Nickel creates the
    # content row. Tabs and newlines are stripped to keep the queue parseable.
    metadata_title=$(printf '%s' "$3" | tr '\t\r\n' '   ')
    metadata_author=$(printf '%s' "$4" | tr '\t\r\n' '   ')
    metadata_series=$(printf '%s' "$5" | tr '\t\r\n' '   ')
    metadata_series_index=$(printf '%s' "$6" | tr '\t\r\n' '   ')

    # Keep local cover copies before the OPDS page disappears. The import
    # worker later places them in Nickel's hashed cache after ImageId exists.
    cover_key=$(printf '%s' "$destination" | cksum | awk '{print $1}')
    cover_dir="$RUN_DIR/pending-covers"
    cover_file=""
    thumbnail_file=""
    mkdir -p "$cover_dir"
    case "$7" in
        http://*|https://*)
            if wget_authenticated -T 30 -O "$cover_dir/$cover_key-cover.tmp" "$7" 2>> "$FETCH_LOG"; then
                mv "$cover_dir/$cover_key-cover.tmp" "$cover_dir/$cover_key-cover.jpg"
                cover_file="$cover_dir/$cover_key-cover.jpg"
            else
                rm -f "$cover_dir/$cover_key-cover.tmp"
            fi
            ;;
    esac
    case "$8" in
        http://*|https://*)
            if wget_authenticated -T 30 -O "$cover_dir/$cover_key-thumb.tmp" "$8" 2>> "$FETCH_LOG"; then
                mv "$cover_dir/$cover_key-thumb.tmp" "$cover_dir/$cover_key-thumb.jpg"
                thumbnail_file="$cover_dir/$cover_key-thumb.jpg"
            else
                rm -f "$cover_dir/$cover_key-thumb.tmp"
            fi
            ;;
    esac
    [ -n "$cover_file" ] || cover_file=$thumbnail_file
    [ -n "$thumbnail_file" ] || thumbnail_file=$cover_file
    printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$destination" "$metadata_title" "$metadata_author" "$metadata_series" "$metadata_series_index" "$cover_file" "$thumbnail_file" >> "$RUN_DIR/metadata_pending.tsv"
    printf '%s-%s\n' "$(date +%s)" "$$" > "$RUN_DIR/import_pending"
    printf 'waiting\n' > "$RUN_DIR/import_state"
    return 0
}

mkdir -p "$CATALOG_DIR"

################################################################****
#  BLOCK 02 - NON-DESTRUCTIVE SOURCE INITIALIZATION
#
#  Existing profiles and credentials always take precedence. Each release
#  offers the bundled public sources once. They may be deleted during normal
#  use, then are proposed again by the next update without replacing the
#  active source. Existing files with matching names are never overwritten.
################################################################****
DEFAULT_RELEASE_FILE="$APP_DIR/config/default_source_release"
DEFAULT_APPLIED_FILE="$APP_DIR/config/.default_source_applied"
DEFAULT_RELEASE=$(sed -n '1p' "$DEFAULT_RELEASE_FILE" 2>/dev/null)
DEFAULT_APPLIED=$(sed -n '1p' "$DEFAULT_APPLIED_FILE" 2>/dev/null)
if [ -n "$DEFAULT_RELEASE" ] && [ "$DEFAULT_RELEASE" != "$DEFAULT_APPLIED" ]; then
    if [ ! -f "$ACTIVE_FILE" ] && [ -f "$CONF_FILE" ]; then
        mv "$CONF_FILE" "$CATALOG_DIR/catalog-1.conf"
        printf 'catalog-1.conf\n' > "$ACTIVE_FILE"
    fi
    first_source=""
    gutenberg_source=""
    feedbooks_source=""
    for candidate in "$CATALOG_DIR"/*.conf; do
        [ -f "$candidate" ] || continue
        [ -n "$first_source" ] || first_source=${candidate##*/}
        candidate_url=$(sed -n 's/^CATALOG_URL=//p' "$candidate" | head -n 1)
        [ "$candidate_url" = "https://www.gutenberg.org/ebooks/search.opds/" ] && gutenberg_source=${candidate##*/}
        [ "$candidate_url" = "http://feedbooks.github.io/opds-test-catalog/catalog/root.xml" ] && feedbooks_source=${candidate##*/}
    done
    if [ -z "$gutenberg_source" ]; then
        umask 077
        gutenberg_source=catalog-gutenberg.conf
        if [ -e "$CATALOG_DIR/$gutenberg_source" ]; then
            release_suffix=$(printf '%s' "$DEFAULT_RELEASE" | sed 's/[^A-Za-z0-9_-]/-/g')
            gutenberg_source="catalog-gutenberg-$release_suffix.conf"
        fi
        cat > "$CATALOG_DIR/$gutenberg_source" <<'EOF'
CONFIGURED=1
CATALOG_NAME=Project Gutenberg
CATALOG_URL=https://www.gutenberg.org/ebooks/search.opds/
USERNAME=
PASSWORD=
HTTP_PORT=8188
EOF
        [ -n "$first_source" ] || first_source=$gutenberg_source
    fi
    if [ -z "$feedbooks_source" ]; then
        umask 077
        feedbooks_source=catalog-opds-test.conf
        if [ -e "$CATALOG_DIR/$feedbooks_source" ]; then
            release_suffix=$(printf '%s' "$DEFAULT_RELEASE" | sed 's/[^A-Za-z0-9_-]/-/g')
            feedbooks_source="catalog-opds-test-$release_suffix.conf"
        fi
        cat > "$CATALOG_DIR/$feedbooks_source" <<'EOF'
CONFIGURED=1
CATALOG_NAME=Feedbooks OPDS Test Catalog
CATALOG_URL=http://feedbooks.github.io/opds-test-catalog/catalog/root.xml
USERNAME=
PASSWORD=
HTTP_PORT=8188
EOF
        [ -n "$first_source" ] || first_source=$feedbooks_source
    fi
    [ -f "$ACTIVE_FILE" ] || printf '%s\n' "$first_source" > "$ACTIVE_FILE"
    printf '%s\n' "$DEFAULT_RELEASE" > "$DEFAULT_APPLIED_FILE"
fi
ACTIVE_ID=$(sed -n '1p' "$ACTIVE_FILE" 2>/dev/null)
if valid_catalog_id "$ACTIVE_ID" && [ -f "$CATALOG_DIR/$ACTIVE_ID" ]; then
    CONF_FILE="$CATALOG_DIR/$ACTIVE_ID"
else
    CONF_FILE=""
    ACTIVE_ID=""
fi
load_catalog

################################################################****
#  BLOCK 03 - READ REQUEST AND SAVE SOURCE SETTINGS
################################################################****
REQUEST_DATA=$QUERY_STRING
if [ "$REQUEST_METHOD" = "POST" ]; then
    case "$CONTENT_LENGTH" in
        ''|*[!0-9]*) CONTENT_LENGTH=0 ;;
    esac
    if [ "$CONTENT_LENGTH" -gt 0 ] && [ "$CONTENT_LENGTH" -le 65535 ]; then
        # BusyBox httpd closes CGI stdin after the declared request body.
        # Reading to EOF is more portable than dd on Kobo firmware builds.
        REQUEST_DATA=$(cat)
    fi
fi

action=$(query_value action "$REQUEST_DATA")
catalog_id=$(query_value catalog_id "$REQUEST_DATA" | tr -d '\r\n')
save_error=""
DOWNLOAD_DIR=/mnt/onboard/KoboOPDS

if [ "$action" = "save_language" ]; then
    selected_language=$(query_value language "$REQUEST_DATA" | tr -d '\r\n')
    case "$selected_language" in auto|it|en|fr|de|es) ;; *) selected_language=auto ;; esac
    umask 077
    printf 'UI_LANGUAGE=%s\n' "$selected_language" > "$UI_CONF.tmp"
    mv "$UI_CONF.tmp" "$UI_CONF"
    chmod 600 "$UI_CONF" 2>/dev/null || true
    UI_LANGUAGE=$selected_language
    if [ "$selected_language" = auto ]; then
        case "$HTTP_ACCEPT_LANGUAGE" in fr*) UI_LANG=fr;; de*) UI_LANG=de;; es*) UI_LANG=es;; en*) UI_LANG=en;; *) UI_LANG=it;; esac
    else
        UI_LANG=$selected_language
    fi
    action=settings
fi

if [ "$action" = "save_download_settings" ]; then
    replace_existing=$(query_value replace_existing "$REQUEST_DATA" | tr -d '\r\n')
    [ "$replace_existing" = 1 ] || replace_existing=0
    umask 077
    preferred_format=$(query_value preferred_format "$REQUEST_DATA" | tr -d '\r\n')
    case "$preferred_format" in kepub|epub|pdf|cbz|cbr) ;; *) preferred_format=epub ;; esac
    {
        printf 'REPLACE_EXISTING=%s\n' "$replace_existing"
        printf 'PREFERRED_FORMAT=%s\n' "$preferred_format"
    } > "$DOWNLOAD_CONF.tmp"
    mv "$DOWNLOAD_CONF.tmp" "$DOWNLOAD_CONF"
    chmod 600 "$DOWNLOAD_CONF" 2>/dev/null || true
    REPLACE_EXISTING=$replace_existing
    PREFERRED_FORMAT=$preferred_format
    action=settings
fi

if [ "$action" = "select" ] && valid_catalog_id "$catalog_id" && [ -f "$CATALOG_DIR/$catalog_id" ]; then
    printf '%s\n' "$catalog_id" > "$ACTIVE_FILE"
    ACTIVE_ID=$catalog_id
    CONF_FILE="$CATALOG_DIR/$catalog_id"
    load_catalog
    invalidate_feed_cache
    action=""
fi

if [ "$action" = "delete" ] && valid_catalog_id "$catalog_id" && [ -f "$CATALOG_DIR/$catalog_id" ]; then
    rm -f "$CATALOG_DIR/$catalog_id"
    [ "$ACTIVE_ID" = "$catalog_id" ] && rm -f "$ACTIVE_FILE"
    ACTIVE_ID="" CONF_FILE="" CONFIGURED=0
    for candidate in "$CATALOG_DIR"/*.conf; do
        [ -f "$candidate" ] || continue
        ACTIVE_ID=${candidate##*/}
        printf '%s\n' "$ACTIVE_ID" > "$ACTIVE_FILE"
        CONF_FILE=$candidate
        load_catalog
        break
    done
    invalidate_feed_cache
    action=settings
fi

if [ "$action" = "edit" ] && valid_catalog_id "$catalog_id" && [ -f "$CATALOG_DIR/$catalog_id" ]; then
    CONF_FILE="$CATALOG_DIR/$catalog_id"
    load_catalog
fi

if [ "$action" = "add" ]; then
    catalog_id=""
    CONFIGURED=0 CATALOG_NAME="" CATALOG_URL="" USERNAME="" PASSWORD=""
fi

if [ "$action" = "save" ]; then
    new_name=$(query_value name "$REQUEST_DATA" | tr -d '\r\n')
    new_url=$(query_value url "$REQUEST_DATA" | tr -d '\r\n')
    new_user=$(query_value username "$REQUEST_DATA" | tr -d '\r\n')
    new_password=$(query_value password "$REQUEST_DATA" | tr -d '\r\n')

    case "$new_url" in
        http://*|https://*) ;;
        *) save_error="L'indirizzo deve iniziare con http:// oppure https://." ;;
    esac

    if [ -n "$save_error" ]; then
        CATALOG_NAME=$new_name CATALOG_URL=$new_url USERNAME=$new_user PASSWORD=$new_password
    fi

    if [ -z "$save_error" ]; then
        umask 077
        if ! valid_catalog_id "$catalog_id"; then
            catalog_id="catalog-$(date +%s).conf"
        fi
        CONF_FILE="$CATALOG_DIR/$catalog_id"
        {
            printf 'CONFIGURED=1\n'
            printf 'CATALOG_NAME=%s\n' "${new_name:-OPDS Catalog}"
            printf 'CATALOG_URL=%s\n' "$new_url"
            printf 'USERNAME=%s\n' "$new_user"
            printf 'PASSWORD=%s\n' "$new_password"
            printf 'HTTP_PORT=8188\n'
        } > "$CONF_FILE.tmp"
        mv "$CONF_FILE.tmp" "$CONF_FILE"
        printf '%s\n' "$catalog_id" > "$ACTIVE_FILE"
        ACTIVE_ID=$catalog_id
        chmod 600 "$CONF_FILE" 2>/dev/null || true
        CONFIGURED=1
        CATALOG_NAME=${new_name:-OPDS Catalog}
        CATALOG_URL=$new_url
        USERNAME=$new_user
        PASSWORD=$new_password
        invalidate_feed_cache
    fi
fi

################################################################****
#  BLOCK 04 - AUTHENTICATED BOOK PROXY
#
#  KoboOPDS writes acquisitions directly to onboard storage. This avoids
#  Nickel Browser's duplicate confirmation dialogs and supports queues.
################################################################****
if [ "$action" = "download" ] && [ "$CONFIGURED" = "1" ]; then
    book_url=$(query_value url "$REQUEST_DATA")
    requested_name=$(query_value name "$REQUEST_DATA" | tr -d '\r\n')
    book_title=$(query_value title "$REQUEST_DATA" | tr -d '\r\n')
    book_author=$(query_value author "$REQUEST_DATA" | tr -d '\r\n')
    book_series=$(query_value series "$REQUEST_DATA" | tr -d '\r\n')
    book_series_index=$(query_value series_index "$REQUEST_DATA" | tr -d '\r\n')
    book_cover=$(query_value cover "$REQUEST_DATA" | tr -d '\r\n')
    book_thumbnail=$(query_value thumbnail "$REQUEST_DATA" | tr -d '\r\n')
    if download_to_kobo "$book_url" "$requested_name" "$book_title" "$book_author" "$book_series" "$book_series_index" "$book_cover" "$book_thumbnail"; then
        "$APP_DIR/bin/import-watcher.sh" >> "$RUN_DIR/import.log" 2>&1 &
        return_location=$(printf '%s' "$HTTP_REFERER" | tr -d '\r\n')
        case "$return_location" in http://127.0.0.1:8188/*|http://localhost:8188/*) ;; *) return_location=/cgi-bin/opds ;; esac
        printf 'Status: 303 See Other\r\nLocation: /cgi-bin/opds?action=import_status&return=%s\r\n\r\n' "$(url_encode "$return_location")"
    else
        download_status=$?
        if [ "$download_status" -eq 3 ]; then
            printf 'Content-Type: text/html; charset=utf-8\r\n\r\n<!doctype html><meta charset="utf-8"><script>history.back()</script><p>File already downloaded.</p>'
            exit 0
        fi
        fetch_error=$(classify_fetch_error)
        printf '%s\n' "$fetch_error" > "$LAST_ERROR_FILE"
        printf 'Status: 502 Bad Gateway\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<!doctype html><meta charset="utf-8"><h1>Download non riuscito</h1><p>%s</p><p><a href="javascript:history.back()">Torna al libro</a></p>' "$(error_message "$fetch_error")"
    fi
    exit 0
fi

if [ "$action" = "download_batch" ] && [ "$CONFIGURED" = "1" ]; then
    printf 'Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:sans-serif;font-size:24px;margin:24px}.ok{color:#173}.fail{color:#900}a{color:#111}button{font-size:22px;padding:12px;margin:12px 0}</style><h1>%s</h1><form method="post" action="/cgi-bin/opds"><input type="hidden" name="action" value="download_batch">' "$(msg batch_results)"
    selected=0 succeeded=0 failed=0 skipped=0
    batch_index=1
    while [ "$batch_index" -le 100 ]; do
        if [ "$(query_value "pick_$batch_index" "$REQUEST_DATA")" = "1" ]; then
            selected=$((selected + 1))
            batch_url=$(query_value "url_$batch_index" "$REQUEST_DATA")
            batch_name=$(query_value "name_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_title=$(query_value "title_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_author=$(query_value "author_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_series=$(query_value "series_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_series_index=$(query_value "series_index_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_cover=$(query_value "cover_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            batch_thumbnail=$(query_value "thumbnail_$batch_index" "$REQUEST_DATA" | tr -d '\r\n')
            if download_to_kobo "$batch_url" "$batch_name" "$batch_title" "$batch_author" "$batch_series" "$batch_series_index" "$batch_cover" "$batch_thumbnail"; then
                succeeded=$((succeeded + 1))
                printf '<p class="ok">✓ %s</p>' "$(printf '%s' "$batch_name" | html_escape)"
            else
                batch_status=$?
                if [ "$batch_status" -eq 3 ]; then
                    skipped=$((skipped + 1))
                    printf '<p>— %s (%s)</p>' "$(printf '%s' "$batch_name" | html_escape)" "$(msg already_downloaded)"
                else
                    failed=$((failed + 1))
                    printf '<p class="fail">✕ %s</p>' "$(printf '%s' "$batch_name" | html_escape)"
                    printf '<input type="hidden" name="pick_%s" value="1"><input type="hidden" name="url_%s" value="%s"><input type="hidden" name="name_%s" value="%s"><input type="hidden" name="title_%s" value="%s"><input type="hidden" name="author_%s" value="%s"><input type="hidden" name="series_%s" value="%s"><input type="hidden" name="series_index_%s" value="%s"><input type="hidden" name="cover_%s" value="%s"><input type="hidden" name="thumbnail_%s" value="%s">' "$batch_index" "$batch_index" "$(printf '%s' "$batch_url" | html_escape)" "$batch_index" "$(printf '%s' "$batch_name" | html_escape)" "$batch_index" "$(printf '%s' "$batch_title" | html_escape)" "$batch_index" "$(printf '%s' "$batch_author" | html_escape)" "$batch_index" "$(printf '%s' "$batch_series" | html_escape)" "$batch_index" "$(printf '%s' "$batch_series_index" | html_escape)" "$batch_index" "$(printf '%s' "$batch_cover" | html_escape)" "$batch_index" "$(printf '%s' "$batch_thumbnail" | html_escape)"
                fi
            fi
        fi
        batch_index=$((batch_index + 1))
    done
    [ "$succeeded" -gt 0 ] && "$APP_DIR/bin/import-watcher.sh" >> "$RUN_DIR/import.log" 2>&1 &
    if [ "$selected" -eq 0 ]; then
        printf '<p>%s</p>' "$(msg select_one)"
    else
        printf '<p><strong>%s:</strong> %s · <strong>%s:</strong> %s · <strong>%s:</strong> %s</p>' "$(msg downloaded)" "$succeeded" "$(msg skipped)" "$skipped" "$(msg failed)" "$failed"
    fi
    [ "$failed" -gt 0 ] && printf '<button type="submit">%s</button>' "$(msg retry_failed)"
    if [ "$succeeded" -gt 0 ]; then
        printf '<meta http-equiv="refresh" content="0;url=/cgi-bin/opds?action=import_status&amp;return=/cgi-bin/opds">'
    fi
    printf '</form><p><a href="javascript:history.back()">%s</a></p>' "$(msg back_page)"
    printf '</body></html>'
    exit 0
fi

if [ "$action" = "import_status" ]; then
    return_location=$(query_value return "$REQUEST_DATA" | tr -d '\r\n')
    case "$return_location" in
        http://127.0.0.1:8188/*|http://localhost:8188/*|/cgi-bin/opds*) ;;
        *) return_location=/cgi-bin/opds ;;
    esac
    import_state=$(sed -n '1p' "$RUN_DIR/import_state" 2>/dev/null)
    [ -n "$import_state" ] || import_state=waiting
    printf 'Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
    case "$import_state" in
        error)
            status_title=$(msg import_failed_title)
            status_text=$(msg import_failed_text)
            ;;
        *)
            printf '<meta http-equiv="refresh" content="2">'
            status_title=$(msg import_wait)
            status_text=$(msg import_wait_text)
            ;;
    esac
    printf '<style>body{font-family:sans-serif;font-size:27px;margin:0;text-align:center}main{margin:22vh 30px}.spinner{display:inline-block;width:48px;height:48px;border:5px solid #aaa;border-top-color:#111;border-radius:50%%}h1{font-size:32px}a{display:inline-block;margin-top:24px;padding:14px 22px;background:#111;color:#fff;text-decoration:none}</style></head><body><main>'
    [ "$import_state" != error ] && printf '<div class="spinner"></div>'
    printf '<h1>%s</h1><p>%s</p>' "$status_title" "$status_text"
    if [ "$import_state" = error ]; then
        printf '<a href="/cgi-bin/opds?action=import_status&amp;return=%s">%s</a>' "$(url_encode "$return_location")" "$(msg retry)"
    fi
    printf '</main></body></html>'
    exit 0
fi

if [ "$action" = "cover" ] && [ "$CONFIGURED" = "1" ]; then
    cover_url=$(query_value url "$REQUEST_DATA")
    case "$cover_url" in http://*|https://*) ;; *) printf 'Status: 400 Bad Request\r\n\r\n'; exit 0;; esac
    cover_key=$(printf '%s' "$cover_url" | cksum | awk '{print $1}')
    cover_dir="$APP_DIR/cache/covers"
    cover_file="$cover_dir/$cover_key.img"
    mkdir -p "$cover_dir"
    cache_kb=$(du -sk "$cover_dir" 2>/dev/null | awk '{print $1}')
    if [ "${cache_kb:-0}" -gt 20480 ]; then
        rm -f "$cover_dir"/*.img
    fi
    if [ ! -s "$cover_file" ]; then
        if ! wget_authenticated -S -T 30 -O "$cover_file.tmp" "$cover_url" 2> "$FETCH_LOG"; then
            rm -f "$cover_file.tmp"
            printf 'Status: 404 Not Found\r\n\r\n'
            exit 0
        fi
        mv "$cover_file.tmp" "$cover_file"
    fi
    printf 'Content-Type: image/jpeg\r\nCache-Control: max-age=86400\r\nContent-Length: %s\r\n\r\n' "$(wc -c < "$cover_file")"
    cat "$cover_file"
    exit 0
fi

################################################################****
#  BLOCK 05 - ON-DEMAND UPDATE CHECK AND VERIFIED INSTALLER DOWNLOAD
#
#  There is no background task. The manifest is checked only on the first
#  catalog request of the day, or when the user explicitly requests it.
################################################################****
load_update_state
if [ "$action" = "check_update" ]; then
    check_for_update 1
    action=update
elif [ -z "$action" ] || [ "$action" = "search" ] || [ "$action" = "details" ]; then
    check_for_update 0
fi

if [ "$action" = "install_update" ]; then
    load_update_state
    if [ -z "$UPDATE_AVAILABLE_VERSION" ]; then
        action=update
    else
        installer=/mnt/onboard/.kobo/KoboRoot.tgz
        if wget -q -T 90 -O "$installer.part" "$UPDATE_PACKAGE_URL"; then
            actual_sha256=$(sha256sum "$installer.part" 2>/dev/null | awk '{print $1}')
            if [ "$actual_sha256" = "$UPDATE_PACKAGE_SHA256" ]; then
                mv "$installer.part" "$installer"
                sync
                action=update_ready
            else
                rm -f "$installer.part"
                action=update_failed
            fi
        else
            rm -f "$installer.part"
            action=update_failed
        fi
    fi
fi

if [ "$action" = "reboot_update" ]; then
    printf 'Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:sans-serif;font-size:26px;margin:28px}</style><h1>%s</h1><p>%s</p>' "$(msg installing_update)" "$(msg rebooting)"
    sync
    (sleep 2; reboot) >/dev/null 2>&1 &
    exit 0
fi

################################################################****
#  BLOCK 06 - COMMON HTML AND SOURCE MANAGEMENT
################################################################****
printf 'Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n'
cat <<'HTML'
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{font-family:sans-serif;margin:0;background:#fff;color:#111;font-size:28px}
header{background:#111;color:#fff;padding:15px 18px}h1{font-size:30px;margin:0}
.item{display:block;padding:14px 18px;border-bottom:2px solid #bbb;color:#111;text-decoration:none}
.select-item{position:relative}.select-item .item{padding-left:80px}.book-select{-webkit-appearance:none;appearance:none;box-sizing:border-box;position:absolute;z-index:4;left:10px;top:10px;width:52px;height:52px;margin:0;border:3px solid #111;border-radius:2px;background:#fff}.book-select:checked{background:#111 url('/icons/select-active.svg') center center/34px 34px no-repeat}
.meta{font-size:21px;color:#555;margin-top:5px;overflow:hidden;white-space:nowrap}.meta-author{display:block;overflow:hidden;text-overflow:ellipsis}.meta-series{float:right;max-width:48%;margin-left:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.box{margin:24px}.error{padding:20px;border:3px solid #111}
label{display:block;margin:20px 0 8px}input{box-sizing:border-box;width:100%;font-size:26px;padding:14px;border:2px solid #555}
button{font-size:28px;padding:18px 28px;margin-top:28px;background:#111;color:#fff;border:0}.settings{float:right;color:#fff}
.toolbar{float:right;white-space:nowrap}.toolbar-icon{display:inline-block;color:#fff;text-decoration:none;font-size:32px;line-height:32px;margin-left:22px;min-width:34px;text-align:center;vertical-align:top}
.toolbar-icon img{display:block;width:34px;height:34px}
.setup{margin:12px 20px;padding-bottom:45vh}.setup label{font-size:21px;margin:8px 0 3px}
.setup input{font-size:22px;padding:8px 10px}.setup button{font-size:23px;padding:11px 20px;margin-top:12px;width:100%}
.search{padding:8px;background:#ddd;white-space:nowrap}.search input{width:72%;font-size:22px;padding:8px 10px}
.search button{width:24%;font-size:22px;padding:10px 4px;margin:0}.home{font-size:21px;padding:9px 18px;display:block;color:#111}
.server{border-bottom:2px solid #aaa;padding:10px 0;overflow:hidden}.small{font-size:19px!important;padding:7px 10px!important;margin:5px 3px!important;width:auto!important}
.source-actions{float:right;white-space:nowrap}.icon-action{box-sizing:content-box;display:inline-block;width:38px;height:38px;line-height:38px;margin:4px 5px;padding:5px;border:2px solid #333;vertical-align:middle;text-align:center;color:#111;text-decoration:none;font-size:34px;font-weight:bold}.icon-action img{display:block;width:100%;height:100%}.active-action{border-width:4px;padding:3px;background:#111}.add-row{text-align:right;padding-top:10px}
.pager{position:-webkit-sticky;position:sticky;top:0;z-index:20;box-sizing:border-box;min-height:53px;text-align:center;padding:7px 62px;border-bottom:3px solid #333;background:#fff;white-space:nowrap}.pager a{box-sizing:content-box;display:inline-block;width:34px;height:34px;margin:1px 3px;padding:4px;border:2px solid #333;vertical-align:middle}.pager a img{display:block;width:100%;height:100%}.pager-label{display:inline-block;min-width:140px;padding:8px 3px;vertical-align:middle;font-size:20px;font-weight:bold}.pager-left{position:absolute;left:5px;top:7px}.pager-left a{margin:1px 2px}.pager .batch-download{box-sizing:content-box;position:absolute;right:8px;top:7px;width:34px;height:34px;margin:1px 0;padding:4px;border:2px solid #333;background:#111}.pager .batch-download img{display:block;width:100%;height:100%}.batch-hidden{display:none!important}
.crumbs{padding:9px 16px;border-bottom:1px solid #aaa;font-size:20px}.crumbs a{color:#111;margin-right:20px}.diag{font-size:21px;line-height:1.5}.ok{border:2px solid #555;padding:12px}
.details{padding:20px}.details h2{font-size:30px;margin:5px 0}.summary{clear:both;display:block;font-size:22px;line-height:1.4;margin-top:18px;padding-top:12px;border-top:2px solid #aaa}.format{display:block;margin:12px 0;padding:16px;border:2px solid #333;color:#111;text-decoration:none;font-weight:bold}.format.preferred{border-width:4px;background:#eee}.format.preferred:after{content:" ★"}
.cover{float:left;width:150px;max-height:220px;object-fit:contain;margin:0 18px 12px 0}.metadata{display:block;font-size:20px;line-height:1.35;margin:10px 0}.metadata div{display:block;padding:6px 0;border-bottom:1px solid #bbb}.metadata strong{display:inline-block;min-width:105px}.clear{clear:both;height:1px}.more{font-size:20px}
.error-actions{text-align:right;margin-top:14px}.error-actions .icon-action{background:#fff}
.credits{margin-bottom:24px;padding-bottom:18px;border-bottom:2px solid #888;text-align:center}.credits strong{display:block;font-size:26px}.credits span{display:block;margin-top:5px}.credits a{color:#111}
.support{text-align:center;line-height:1.4}.support img{display:block;width:310px;height:310px;max-width:88%;margin:20px auto;image-rendering:pixelated}.support-address{display:inline-block;padding:12px 16px;border:2px solid #555;font-family:monospace;font-size:20px}
.update{font-size:21px;line-height:1.5}.update-version{font-size:30px;font-weight:bold}.update-actions a,.update-actions button{display:block;box-sizing:border-box;width:100%;margin-top:18px;padding:14px;border:2px solid #111;background:#111;color:#fff;text-align:center;text-decoration:none;font-size:23px}.update-actions .secondary{background:#fff;color:#111}.update-icon{position:relative}.update-dot{position:absolute;right:-5px;top:-5px;width:12px;height:12px;border:2px solid #fff;border-radius:50%;background:#8d42c4}
.check-update-button{display:inline-block;margin-top:8px;padding:12px 18px;border:2px solid #111;background:#111;color:#fff;text-decoration:none;font-size:22px;font-weight:bold}
</style><script>
function batchStorageKey(){var form=document.getElementById('batch-form');return form?'koboopds-selection:'+form.getAttribute('data-selection-key'):'';}
function updateBatchButton(){var boxes=document.getElementsByClassName('book-select'),button=document.getElementById('batch-download'),selected=false,i;if(!button)return;for(i=0;i<boxes.length;i++){if(boxes[i].checked){selected=true;break;}}button.className=selected?'batch-download':'batch-download batch-hidden';}
function saveBatchSelection(){var boxes=document.getElementsByClassName('book-select'),saved=[],key=batchStorageKey(),i;if(!key)return;for(i=0;i<boxes.length;i++){if(boxes[i].checked)saved.push(boxes[i].name);}try{localStorage.setItem(key,saved.join(','));}catch(e){}updateBatchButton();}
function restoreBatchSelection(){var boxes=document.getElementsByClassName('book-select'),key=batchStorageKey(),saved='',wanted={},i;if(!key){updateBatchButton();return;}try{saved=localStorage.getItem(key)||'';}catch(e){}if(saved){saved=saved.split(',');for(i=0;i<saved.length;i++)wanted[saved[i]]=1;for(i=0;i<boxes.length;i++)boxes[i].checked=!!wanted[boxes[i].name];}updateBatchButton();}
function clearBatchSelection(){var key=batchStorageKey();try{if(key)localStorage.removeItem(key);}catch(e){}return true;}
</script></head><body>
HTML

update_toolbar=""
if [ -n "$UPDATE_AVAILABLE_VERSION" ]; then
    update_toolbar='<a class="toolbar-icon update-icon" href="/cgi-bin/opds?action=update" title="Update" aria-label="Update"><img src="/icons/update.svg" alt=""><span class="update-dot"></span></a>'
fi

if [ "$action" = "update" ] || [ "$action" = "update_ready" ] || [ "$action" = "update_failed" ]; then
    printf '<header><span class="toolbar"><a class="toolbar-icon" href="/cgi-bin/opds" title="%s" aria-label="%s"><img src="/icons/book.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=settings" title="%s" aria-label="%s"><img src="/icons/settings.svg" alt=""></a></span><h1>%s</h1></header><div class="box update">' "$(msg catalog)" "$(msg catalog)" "$(msg settings)" "$(msg settings)" "$(msg updates)"
    if [ "$action" = "update_ready" ]; then
        printf '<p class="update-version">%s</p><p>%s</p><div class="update-actions"><a href="/cgi-bin/opds?action=reboot_update">%s</a></div>' "$(msg update_ready)" "$(msg settings_preserved)" "$(msg install_reboot)"
    elif [ "$action" = "update_failed" ]; then
        printf '<p class="error">%s</p><div class="update-actions"><a href="/cgi-bin/opds?action=install_update">%s</a><a class="secondary" href="/cgi-bin/opds?action=diagnostics">%s</a></div>' "$(msg update_failed)" "$(msg retry)" "$(msg diagnostics)"
    elif [ -n "$UPDATE_AVAILABLE_VERSION" ]; then
        printf '<p>%s</p><div class="update-version">KoboOPDS %s</div><p>%s: %s</p><p>%s</p><div class="update-actions"><a href="/cgi-bin/opds?action=install_update">%s</a><a class="secondary" href="/cgi-bin/opds">%s</a></div>' "$(msg update_available)" "$(printf '%s' "$UPDATE_AVAILABLE_VERSION" | html_escape)" "$(msg installed_version)" "$APP_VERSION" "$(printf '%s' "$UPDATE_NOTES" | html_escape)" "$(msg install_update)" "$(msg later)"
    else
        printf '<p>%s</p><p>%s: %s</p><div class="update-actions"><a href="/cgi-bin/opds?action=check_update">%s</a><a class="secondary" href="/cgi-bin/opds">%s</a></div>' "$(msg up_to_date)" "$(msg installed_version)" "$APP_VERSION" "$(msg check_now)" "$(msg catalog)"
    fi
    printf '</div></body></html>'
    exit 0
fi

show_setup=0
[ "$CONFIGURED" != "1" ] && show_setup=1
[ "$action" = "settings" ] && show_setup=1
[ "$action" = "add" ] && show_setup=1
[ "$action" = "edit" ] && show_setup=1
[ "$action" = "delete_confirm" ] && show_setup=1
[ -n "$save_error" ] && show_setup=1

if [ "$show_setup" -eq 1 ]; then
    printf '<header style="padding:10px 18px"><span class="toolbar">%s<a class="toolbar-icon" href="/cgi-bin/opds?action=donate" title="%s" aria-label="%s"><img src="/icons/heart.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds" title="%s" aria-label="%s"><img src="/icons/book.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=diagnostics" title="%s" aria-label="%s"><img src="/icons/info.svg" alt=""></a></span><h1 style="font-size:26px">%s</h1></header><div class="box setup">\n' "$update_toolbar" "$(msg donate)" "$(msg donate)" "$(msg catalog)" "$(msg catalog)" "$(msg diagnostics)" "$(msg diagnostics)" "$(msg catalogs)"
    printf '<form method="post"><input type="hidden" name="action" value="save_language"><label>%s</label><select name="language" style="font-size:22px;padding:8px"><option value="auto"%s>%s</option><option value="it"%s>Italiano</option><option value="en"%s>English</option><option value="fr"%s>Français</option><option value="de"%s>Deutsch</option><option value="es"%s>Español</option></select><button class="small">OK</button></form>' "$(msg language)" "$(selected_language_attribute auto)" "$(msg automatic_language)" "$(selected_language_attribute it)" "$(selected_language_attribute en)" "$(selected_language_attribute fr)" "$(selected_language_attribute de)" "$(selected_language_attribute es)"
    replace_skip_selected=""
    replace_overwrite_selected=""
    if [ "$REPLACE_EXISTING" = 1 ]; then replace_overwrite_selected=" selected"; else replace_skip_selected=" selected"; fi
    printf '<form method="post"><input type="hidden" name="action" value="save_download_settings"><label>%s</label><select name="replace_existing" style="font-size:22px;padding:8px"><option value="0"%s>%s</option><option value="1"%s>%s</option></select><label>%s</label><select name="preferred_format" style="font-size:22px;padding:8px">' "$(msg existing_books)" "$replace_skip_selected" "$(msg skip_existing)" "$replace_overwrite_selected" "$(msg replace_existing)" "$(msg preferred_format)"
    for format_option in kepub epub pdf cbz cbr; do
        format_selected=""; [ "$PREFERRED_FORMAT" = "$format_option" ] && format_selected=" selected"
        printf '<option value="%s"%s>%s</option>' "$format_option" "$format_selected" "$(printf '%s' "$format_option" | tr '[:lower:]' '[:upper:]')"
    done
    printf '</select><button class="small">OK</button></form>'
    if [ "$action" = "delete_confirm" ] && valid_catalog_id "$catalog_id" && [ -f "$CATALOG_DIR/$catalog_id" ]; then
        delete_name=$(sed -n 's/^CATALOG_NAME=//p' "$CATALOG_DIR/$catalog_id" | head -n 1)
        printf '<div class="error"><strong>%s: %s?</strong><form method="post"><input type="hidden" name="action" value="delete"><input type="hidden" name="catalog_id" value="%s"><button class="small">%s</button></form> <a href="/cgi-bin/opds?action=settings">%s</a></div>' "$(msg delete)" "$(printf '%s' "$delete_name" | html_escape)" "$catalog_id" "$(msg confirm_delete)" "$(msg cancel)"
    fi
    for server_file in "$CATALOG_DIR"/*.conf; do
        [ -f "$server_file" ] || continue
        server_id=${server_file##*/}
        server_name=$(sed -n 's/^CATALOG_NAME=//p' "$server_file" | head -n 1)
        server_url=$(sed -n 's/^CATALOG_URL=//p' "$server_file" | head -n 1)
        select_class=icon-action
        select_icon=select.svg
        if [ "$server_id" = "$ACTIVE_ID" ]; then
            select_class="icon-action active-action"
            select_icon=select-active.svg
        fi
        printf '<div class="server"><span class="source-actions"><a class="%s" href="/cgi-bin/opds?action=select&amp;catalog_id=%s" title="%s" aria-label="%s"><img src="/icons/%s" alt=""></a><a class="icon-action" href="/cgi-bin/opds?action=edit&amp;catalog_id=%s" title="%s" aria-label="%s"><img src="/icons/edit.svg" alt=""></a><a class="icon-action" href="/cgi-bin/opds?action=delete_confirm&amp;catalog_id=%s" title="%s" aria-label="%s"><img src="/icons/delete.svg" alt=""></a></span><strong>%s</strong><div class="meta">%s</div>' "$select_class" "$server_id" "$(msg use)" "$(msg use)" "$select_icon" "$server_id" "$(msg edit)" "$(msg edit)" "$server_id" "$(msg delete)" "$(msg delete)" "$(printf '%s' "$server_name" | html_escape)" "$(printf '%s' "$server_url" | html_escape)"
        printf '</div>'
    done
    printf '<div class="add-row"><a class="icon-action" href="/cgi-bin/opds?action=add" title="%s" aria-label="%s">+</a></div>' "$(msg add_source)" "$(msg add_source)"
    [ -n "$save_error" ] && printf '<div class="error">%s</div>' "$(printf '%s' "$save_error" | html_escape)"
    if [ "$action" = "add" ] || [ "$action" = "edit" ] || [ -n "$save_error" ]; then
    cat <<HTML
<h2 style="font-size:23px">$(msg source_details)</h2>
<form method="post" action="/cgi-bin/opds">
<input type="hidden" name="action" value="save">
<input type="hidden" name="catalog_id" value="$(printf '%s' "$catalog_id" | html_escape)">
<label>$(msg catalog_name)</label><input name="name" value="$(printf '%s' "$CATALOG_NAME" | html_escape)" required>
<label>$(msg address)</label><input name="url" type="url" value="$(printf '%s' "$CATALOG_URL" | html_escape)" required>
<label>$(msg username)</label><input name="username" value="$(printf '%s' "$USERNAME" | html_escape)">
<label>$(msg password)</label><input name="password" type="password" value="$(printf '%s' "$PASSWORD" | html_escape)">
<button type="submit">$(msg save_open)</button></form></div></body></html>
HTML
    else
        printf '<h2 style="font-size:23px">%s</h2><p class="meta">%s: %s</p><a class="check-update-button" href="/cgi-bin/opds?action=check_update">%s</a>' "$(msg updates)" "$(msg installed_version)" "$APP_VERSION" "$(msg check_now)"
        printf '</div></body></html>\n'
    fi
    exit 0
fi

if [ "$action" = "diagnostics" ]; then
    last_error=$(sed -n '1p' "$LAST_ERROR_FILE" 2>/dev/null)
    [ -n "$last_error" ] || last_error=NONE
    free_space=$(df -k /mnt/onboard 2>/dev/null | awk 'NR==2 {printf "%d MB", $4/1024}')
    printf '<header><span class="toolbar">%s<a class="toolbar-icon" href="/cgi-bin/opds?action=donate" title="%s" aria-label="%s"><img src="/icons/heart.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds" title="%s" aria-label="%s"><img src="/icons/book.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=settings" title="%s" aria-label="%s"><img src="/icons/settings.svg" alt=""></a></span><h1>Info</h1></header><div class="box diag">' "$update_toolbar" "$(msg donate)" "$(msg donate)" "$(msg catalog)" "$(msg catalog)" "$(msg settings)" "$(msg settings)"
    printf '<div class="credits"><strong>KoboOPDS</strong><span>Created by Luca Calcinai · <a href="https://effecta.it">Effecta.it</a></span><span>Developed with help from CiccioAI</span><span>© 2026</span></div>'
    printf '<p><strong>Version:</strong> %s</p>' "$APP_VERSION"
    printf '<p><strong>Active catalog:</strong> %s</p>' "$(printf '%s' "$CATALOG_NAME" | html_escape)"
    printf '<p><strong>Address:</strong> %s</p>' "$(printf '%s' "$CATALOG_URL" | html_escape)"
    printf '<p><strong>Free space:</strong> %s</p>' "${free_space:-unavailable}"
    if [ -s "$RUN_DIR/metadata_pending.tsv" ]; then
        pending_metadata=$(wc -l < "$RUN_DIR/metadata_pending.tsv" | tr -d ' ')
        printf '<p><strong>Metadata queue:</strong> %s pending</p>' "$pending_metadata"
    else
        printf '<p><strong>Metadata queue:</strong> empty</p>'
    fi
    if [ -f "$RUN_DIR/import_pending" ]; then
        printf '<p><strong>Library import:</strong> pending or retrying</p>'
    else
        printf '<p><strong>Library import:</strong> idle</p>'
    fi
    if [ "$last_error" = "NONE" ]; then
        printf '<p class="ok">No network error recorded.</p>'
    else
        printf '<p class="error"><strong>Last error:</strong> %s</p>' "$(error_message "$last_error")"
    fi
    printf '<p>Credentials and HTTP authorization headers are never displayed.</p>'
    printf '</div></body></html>'
    exit 0
fi

if [ "$action" = "donate" ]; then
    printf '<header><span class="toolbar"><a class="toolbar-icon" href="/cgi-bin/opds" title="%s" aria-label="%s"><img src="/icons/book.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=diagnostics" title="%s" aria-label="%s"><img src="/icons/info.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=settings" title="%s" aria-label="%s"><img src="/icons/settings.svg" alt=""></a></span><h1>%s</h1></header><div class="box support">' "$(msg catalog)" "$(msg catalog)" "$(msg diagnostics)" "$(msg diagnostics)" "$(msg settings)" "$(msg settings)" "$(msg support_title)"
    printf '<p>%s</p><p>%s</p>' "$(msg support_intro)" "$(msg support_scan)"
    printf '<img src="/icons/donate-qr.svg" alt="QR code: paypal.me/effecta/3">'
    printf '<p>%s</p><div class="support-address">paypal.me/effecta/3</div>' "$(msg support_address)"
    printf '</div></body></html>'
    exit 0
fi

search_query=$(query_value q "$REQUEST_DATA" | tr -d '\r\n')
printf '<header><span class="toolbar">%s<a class="toolbar-icon" href="/cgi-bin/opds?action=donate" title="%s" aria-label="%s"><img src="/icons/heart.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=diagnostics" title="%s" aria-label="%s"><img src="/icons/info.svg" alt=""></a><a class="toolbar-icon" href="/cgi-bin/opds?action=settings" title="%s" aria-label="%s"><img src="/icons/settings.svg" alt=""></a></span><h1>%s</h1></header>\n' "$update_toolbar" "$(msg donate)" "$(msg donate)" "$(msg diagnostics)" "$(msg diagnostics)" "$(msg settings)" "$(msg settings)" "$(printf '%s' "$CATALOG_NAME" | html_escape)"
cat <<HTML
<form class="search" method="get" action="/cgi-bin/opds">
<input type="hidden" name="action" value="search">
<input name="q" value="$(printf '%s' "$search_query" | html_escape)" placeholder="$(msg search_hint)" required>
<button type="submit">$(msg search)</button>
</form>
HTML
if [ -n "$search_query" ]; then
    printf '<a class="home" href="/cgi-bin/opds">%s</a>\n' "$(msg back_catalog)"
fi

################################################################****
#  BLOCK 06 - FETCH THE CURRENT OPDS PAGE
################################################################****
requested_url=$(query_value url "$REQUEST_DATA" | tr -d '\r\n')
force_refresh=$(query_value refresh "$REQUEST_DATA" | tr -d '\r\n')
if [ "$action" = "search" ] && [ -n "$search_query" ]; then
    search_template=$(discover_search_template)
    if [ -n "$search_template" ]; then
        current_url=$(build_search_url "$search_template" "$search_query")
    else
        # Compatibility fallback for Calibre catalogs which do not expose a
        # usable OpenSearch description in their root feed.
        current_url="${CATALOG_URL%/}/search/$(url_encode "$search_query")"
    fi
else
    case "$requested_url" in
        http://*|https://*) current_url=$requested_url ;;
        *) current_url=$CATALOG_URL ;;
    esac
fi

mkdir -p "$RUN_DIR"
cache_hit=0
now=$(date +%s)
if [ "$force_refresh" != 1 ] && [ -s "$CACHE_FILE" ] && [ -s "$CACHE_URL_FILE" ] && [ -s "$CACHE_TIME_FILE" ]; then
    cached_url=$(sed -n '1p' "$CACHE_URL_FILE")
    cached_time=$(sed -n '1p' "$CACHE_TIME_FILE")
    case "$cached_time" in ''|*[!0-9]*) cache_age=$CACHE_TTL ;; *) cache_age=$((now - cached_time)) ;; esac
    if [ "$cached_url" = "$current_url" ] && [ "$cache_age" -ge 0 ] && [ "$cache_age" -lt "$CACHE_TTL" ]; then
        cache_hit=1
    fi
fi
if [ "$cache_hit" -eq 0 ]; then
    if ! wget_authenticated -S -T 30 -O "$CACHE_FILE.tmp" "$current_url" 2> "$FETCH_LOG"; then
        rm -f "$CACHE_FILE.tmp"
        fetch_error=$(classify_fetch_error)
        printf '%s\n' "$fetch_error" > "$LAST_ERROR_FILE"
        printf '<div class="box error"><div>%s</div><div class="error-actions"><a class="icon-action" href="javascript:location.reload()" title="%s" aria-label="%s"><img src="/icons/retry.svg" alt=""></a><a class="icon-action" href="/cgi-bin/opds?action=diagnostics" title="%s" aria-label="%s"><img src="/icons/wrench.svg" alt=""></a></div></div></body></html>\n' "$(error_message "$fetch_error")" "$(msg retry)" "$(msg retry)" "$(msg open_diag)" "$(msg open_diag)"
        exit 0
    fi
    mv "$CACHE_FILE.tmp" "$CACHE_FILE"
    umask 077
    printf '%s\n' "$current_url" > "$CACHE_URL_FILE.tmp"
    printf '%s\n' "$now" > "$CACHE_TIME_FILE.tmp"
    mv "$CACHE_URL_FILE.tmp" "$CACHE_URL_FILE"
    mv "$CACHE_TIME_FILE.tmp" "$CACHE_TIME_FILE"
fi
rm -f "$LAST_ERROR_FILE"

################################################################****
#  BLOCK 07 - BOOK DETAIL AND FORMAT SELECTION
################################################################****
if [ "$action" = "details" ]; then
    entry_number=$(query_value entry "$REQUEST_DATA")
    case "$entry_number" in ''|*[!0-9]*) entry_number=0 ;; esac
    selected_entry=$(tr '\n\r' '  ' < "$CACHE_FILE" | sed 's|</entry>|</entry>\n|g' | grep '<entry' | sed -n "${entry_number}p")
    if [ -z "$selected_entry" ]; then
        printf '<div class="box error">%s</div></body></html>' "$(msg book_missing)"
        exit 0
    fi

    detail_title=$(printf '%s' "$selected_entry" | sed -n 's|.*<title[^>]*>\([^<]*\)</title>.*|\1|p')
    detail_author=$(printf '%s' "$selected_entry" | sed -n 's|.*<author[^>]*>.*<name[^>]*>\([^<]*\)</name>.*|\1|p')
    detail_summary=$(printf '%s' "$selected_entry" | sed -n 's|.*<summary[^>]*>\(.*\)</summary>.*|\1|p')
    [ -n "$detail_summary" ] || detail_summary=$(printf '%s' "$selected_entry" | sed -n 's|.*<content[^>]*>\(.*\)</content>.*|\1|p')
    detail_summary=$(printf '%s' "$detail_summary" | sed 's/<[^>]*>/ /g')
    detail_series=$(printf '%s' "$selected_entry" | sed -n 's|.*<calibre:series[^>]*>\([^<]*\)</calibre:series>.*|\1|p')
    detail_series_index=$(printf '%s' "$selected_entry" | sed -n 's|.*<calibre:series_index[^>]*>\([^<]*\)</calibre:series_index>.*|\1|p')
    # Some Calibre-compatible servers embed series metadata anywhere in the summary.
    if [ -z "$detail_series" ]; then
        detail_series=$(printf '%s' "$detail_summary" | sed -n 's/.*[Ss][Ee][Rr][Ii][Ee][Ss]:[[:space:]]*\([^[]*\)[[:space:]]*\[[[:space:]]*[0-9.]*[[:space:]]*\].*/\1/p' | sed 's/[[:space:]]*$//')
        detail_series_index=$(printf '%s' "$detail_summary" | sed -n 's/.*[Ss][Ee][Rr][Ii][Ee][Ss]:[[:space:]]*[^[]*[[:space:]]*\[[[:space:]]*\([0-9.]*\)[[:space:]]*\].*/\1/p')
        [ -n "$detail_series" ] && detail_summary=$(printf '%s' "$detail_summary" | sed 's/[[:space:]]*[Ss][Ee][Rr][Ii][Ee][Ss]:[[:space:]]*[^[]*[[:space:]]*\[[[:space:]]*[0-9.]*[[:space:]]*\][[:space:]]*/ /')
    fi
    detail_series_index=$(printf '%s' "$detail_series_index" | sed 's/\.0$//')
    detail_language=$(printf '%s' "$selected_entry" | sed -n 's|.*<dc:language[^>]*>\([^<]*\)</dc:language>.*|\1|p')
    detail_publisher=$(printf '%s' "$selected_entry" | sed -n 's|.*<dc:publisher[^>]*>\([^<]*\)</dc:publisher>.*|\1|p')
    detail_date=$(printf '%s' "$selected_entry" | sed -n 's|.*<dc:date[^>]*>\([^<]*\)</dc:date>.*|\1|p')
    detail_identifier=$(printf '%s' "$selected_entry" | sed -n 's|.*<dc:identifier[^>]*>\([^<]*\)</dc:identifier>.*|\1|p')
    detail_links=$(printf '%s' "$selected_entry" | sed 's|<link|\n<link|g')
    thumbnail_tag=$(printf '%s\n' "$detail_links" | grep 'opds-spec.org/image/thumbnail' | head -n 1)
    cover_tag=$(printf '%s\n' "$detail_links" | grep 'opds-spec.org/cover\|opds-spec.org/image"' | grep -v 'image/thumbnail' | head -n 1)
    [ -n "$cover_tag" ] || cover_tag=$thumbnail_tag
    [ -n "$thumbnail_tag" ] || thumbnail_tag=$cover_tag
    cover_url=$(printf '%s' "$cover_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
    thumbnail_url=$(printf '%s' "$thumbnail_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
    [ -n "$cover_url" ] && cover_url=$(resolve_url "$cover_url" "$current_url")
    [ -n "$thumbnail_url" ] && thumbnail_url=$(resolve_url "$thumbnail_url" "$current_url")
    show_more=$(query_value more "$REQUEST_DATA")

    printf '<div class="crumbs"><a href="/cgi-bin/opds?url=%s">%s</a></div>' "$(url_encode "$current_url")" "$(msg back_page)"
    printf '<article class="details"><h2>%s</h2>' "$(printf '%s' "$detail_title" | html_escape)"
    [ -n "$cover_url" ] && printf '<img class="cover" src="/cgi-bin/opds?action=cover&amp;url=%s" alt="">' "$(url_encode "$cover_url")"
    [ -n "$detail_author" ] && printf '<div class="meta">%s</div>' "$(printf '%s' "$detail_author" | html_escape)"
    printf '<div class="metadata">'
    [ -n "$detail_series" ] && printf '<div><strong>Series:</strong> %s %s</div>' "$(printf '%s' "$detail_series" | html_escape)" "$(printf '%s' "$detail_series_index" | html_escape)"
    [ -n "$detail_language" ] && printf '<div><strong>Language:</strong> %s</div>' "$(printf '%s' "$detail_language" | html_escape)"
    [ -n "$detail_publisher" ] && printf '<div><strong>Publisher:</strong> %s</div>' "$(printf '%s' "$detail_publisher" | html_escape)"
    [ -n "$detail_date" ] && printf '<div><strong>Date:</strong> %s</div>' "$(printf '%s' "$detail_date" | html_escape)"
    [ -n "$detail_identifier" ] && printf '<div><strong>ID:</strong> %s</div>' "$(printf '%s' "$detail_identifier" | html_escape)"
    printf '</div><div class="clear"></div>'
    if [ -n "$detail_summary" ]; then
        summary_length=$(printf '%s' "$detail_summary" | wc -c)
        if [ "$summary_length" -gt 500 ] && [ "$show_more" != 1 ]; then
            short_summary=$(printf '%s' "$detail_summary" | cut -c 1-500)
            printf '<p class="summary">%s… <a class="more" href="/cgi-bin/opds?action=details&amp;entry=%s&amp;url=%s&amp;more=1">More</a></p>' "$(printf '%s' "$short_summary" | html_escape)" "$entry_number" "$(url_encode "$current_url")"
        else
            printf '<p class="summary">%s</p>' "$(printf '%s' "$detail_summary" | html_escape)"
        fi
    fi
    printf '<h3>%s</h3>' "$(msg formats)"

    format_count=0
    printf '%s' "$selected_entry" | sed 's|<link|\n<link|g' | grep 'opds-spec.org/acquisition' | while IFS= read -r link_tag; do
        format_url=$(printf '%s' "$link_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
        format_mime=$(printf '%s' "$link_tag" | sed -n 's|.*type="\([^"]*\)".*|\1|p')
        [ -n "$format_url" ] || continue
        format_url=$(resolve_url "$format_url" "$current_url")
        case "$format_mime" in
            application/x-kobo-epub+zip|application/vnd.kobo.epub+zip) format_label=KEPUB; extension=kepub.epub ;;
            application/epub+zip) format_label=EPUB; extension=epub ;;
            application/pdf) format_label=PDF; extension=pdf ;;
            application/x-cbz|application/vnd.comicbook+zip) format_label=CBZ; extension=cbz ;;
            application/x-cbr) format_label=CBR; extension=cbr ;;
            *) format_label=${format_mime##*/}; extension=book ;;
        esac
        download_name="${detail_title}.${extension}"
        preferred_class=""; [ "$(printf '%s' "$format_label" | tr '[:upper:]' '[:lower:]')" = "$PREFERRED_FORMAT" ] && preferred_class=" preferred"
        printf '<a class="format%s" href="/cgi-bin/opds?action=download&amp;url=%s&amp;mime=%s&amp;name=%s&amp;title=%s&amp;author=%s&amp;series=%s&amp;series_index=%s&amp;cover=%s&amp;thumbnail=%s">%s %s</a>' "$preferred_class" "$(url_encode "$format_url")" "$(url_encode "$format_mime")" "$(url_encode "$download_name")" "$(url_encode "$detail_title")" "$(url_encode "$detail_author")" "$(url_encode "$detail_series")" "$(url_encode "$detail_series_index")" "$(url_encode "$cover_url")" "$(url_encode "$thumbnail_url")" "$(msg download)" "$(printf '%s' "$format_label" | html_escape)"
    done
    printf '</article></body></html>'
    exit 0
fi

################################################################****
#  BLOCK 07 - BREADCRUMBS, ENTRIES AND OPDS PAGINATION
################################################################****
flat_feed=$(tr '\n\r' '  ' < "$CACHE_FILE")
up_url=$(printf '%s' "$flat_feed" | sed -n 's|.*<link[^>]*rel="up"[^>]*href="\([^"]*\)"[^>]*>.*|\1|p')
[ -n "$up_url" ] || up_url=$(printf '%s' "$flat_feed" | sed -n 's|.*<link[^>]*href="\([^"]*\)"[^>]*rel="up"[^>]*>.*|\1|p')
if [ -n "$up_url" ]; then
    up_url=$(resolve_url "$up_url" "$current_url")
fi
extract_rel_url() {
    relation=$1
    found=$(printf '%s' "$flat_feed" | sed -n "s|.*<link[^>]*rel=\"$relation\"[^>]*href=\"\([^\"]*\)\"[^>]*>.*|\1|p")
    [ -n "$found" ] || found=$(printf '%s' "$flat_feed" | sed -n "s|.*<link[^>]*href=\"\([^\"]*\)\"[^>]*rel=\"$relation\"[^>]*>.*|\1|p")
    [ -n "$found" ] && resolve_url "$found" "$current_url"
}

first_url=$(extract_rel_url first)
previous_url=$(extract_rel_url previous)
next_url=$(extract_rel_url next)
last_url=$(extract_rel_url last)

current_offset=$(printf '%s' "$current_url" | sed -n 's/.*[?&]offset=\([0-9][0-9]*\).*/\1/p')
[ -n "$current_offset" ] || current_offset=0
next_offset=$(printf '%s' "$next_url" | sed -n 's/.*[?&]offset=\([0-9][0-9]*\).*/\1/p')
previous_offset=$(printf '%s' "$previous_url" | sed -n 's/.*[?&]offset=\([0-9][0-9]*\).*/\1/p')
last_offset=$(printf '%s' "$last_url" | sed -n 's/.*[?&]offset=\([0-9][0-9]*\).*/\1/p')

page_size=0
[ -n "$next_offset" ] && page_size=$((next_offset - current_offset))
[ "$page_size" -le 0 ] && [ -n "$previous_offset" ] && page_size=$((current_offset - previous_offset))

page_label=""
if [ "$page_size" -gt 0 ] && [ -n "$last_offset" ]; then
    current_page=$((current_offset / page_size + 1))
    total_pages=$((last_offset / page_size + 1))
    page_label="$(msg page) $current_page / $total_pages"
fi

# Render only the navigation relations actually advertised by the server.
# Calibre supplies all four links; simpler OPDS feeds often supply only next.
printf '<form id="batch-form" method="post" action="/cgi-bin/opds" data-selection-key="%s" onsubmit="return clearBatchSelection()"><input type="hidden" name="action" value="download_batch">' "$(url_encode "$ACTIVE_ID|$current_url")"
at_home=0
[ "${current_url%/}" = "${CATALOG_URL%/}" ] && at_home=1
has_books=0
printf '%s' "$flat_feed" | grep -q 'opds-spec.org/acquisition' && has_books=1
if [ "$at_home" -eq 0 ] || [ -n "$up_url$first_url$previous_url$next_url$last_url" ] || [ "$has_books" -eq 1 ]; then
    printf '<nav class="pager"><span class="pager-left">'
    [ "$at_home" -eq 0 ] && printf '<a href="/cgi-bin/opds" title="Home" aria-label="Home"><img src="/icons/home.svg" alt=""></a>'
    [ -n "$up_url" ] && printf '<a href="/cgi-bin/opds?url=%s" title="%s" aria-label="%s"><img src="/icons/up.svg" alt=""></a>' "$(url_encode "$up_url")" "$(msg up)" "$(msg up)"
    printf '<a href="/cgi-bin/opds?url=%s&amp;q=%s&amp;refresh=1" title="%s" aria-label="%s"><img src="/icons/retry.svg" alt=""></a>' "$(url_encode "$current_url")" "$(url_encode "$search_query")" "$(msg refresh)" "$(msg refresh)"
    printf '</span>'
    if [ -n "$first_url$previous_url$next_url$last_url" ]; then
    if [ -n "$first_url" ] && [ -n "$previous_url" ]; then
        printf '<a href="/cgi-bin/opds?url=%s" title="%s" aria-label="%s"><img src="/icons/page-first.svg" alt=""></a>' "$(url_encode "$first_url")" "$(msg first_page)" "$(msg first_page)"
    fi
    if [ -n "$previous_url" ]; then
        printf '<a href="/cgi-bin/opds?url=%s" title="%s" aria-label="%s"><img src="/icons/page-previous.svg" alt=""></a>' "$(url_encode "$previous_url")" "$(msg previous_page)" "$(msg previous_page)"
    fi
    [ -n "$page_label" ] && printf '<span class="pager-label">%s</span>' "$page_label"
    if [ -n "$next_url" ]; then
        printf '<a href="/cgi-bin/opds?url=%s" title="%s" aria-label="%s"><img src="/icons/page-next.svg" alt=""></a>' "$(url_encode "$next_url")" "$(msg next_page)" "$(msg next_page)"
    fi
    if [ -n "$last_url" ] && [ -n "$next_url" ]; then
        printf '<a href="/cgi-bin/opds?url=%s" title="%s" aria-label="%s"><img src="/icons/page-last.svg" alt=""></a>' "$(url_encode "$last_url")" "$(msg last_page)" "$(msg last_page)"
    fi
    fi
    [ "$has_books" -eq 1 ] && printf '<button id="batch-download" class="batch-download batch-hidden" type="submit" title="%s" aria-label="%s"><img src="/icons/download-selected.svg" alt=""></button>' "$(msg download_selected)" "$(msg download_selected)"
    printf '</nav>'
fi
entry_index=0
tr '\n\r' '  ' < "$CACHE_FILE" | sed 's|</entry>|</entry>\n|g' | while IFS= read -r entry; do
    case "$entry" in *'<entry'* ) ;; * ) continue ;; esac
    entry_index=$((entry_index + 1))
    title=$(printf '%s' "$entry" | sed -n 's|.*<title[^>]*>\([^<]*\)</title>.*|\1|p')
    author=$(printf '%s' "$entry" | sed -n 's|.*<author[^>]*>.*<name[^>]*>\([^<]*\)</name>.*|\1|p')
    series=$(printf '%s' "$entry" | sed -n 's|.*<calibre:series[^>]*>\([^<]*\)</calibre:series>.*|\1|p')
    series_index=$(printf '%s' "$entry" | sed -n 's|.*<calibre:series_index[^>]*>\([^<]*\)</calibre:series_index>.*|\1|p')
    if [ -z "$series" ]; then
        entry_summary=$(printf '%s' "$entry" | sed -n 's|.*<summary[^>]*>\(.*\)</summary>.*|\1|p' | sed 's/<[^>]*>/ /g')
        [ -n "$entry_summary" ] || entry_summary=$(printf '%s' "$entry" | sed -n 's|.*<content[^>]*>\(.*\)</content>.*|\1|p' | sed 's/<[^>]*>/ /g')
        series=$(printf '%s' "$entry_summary" | sed -n 's/.*[Ss][Ee][Rr][Ii][Ee][Ss]:[[:space:]]*\([^[]*\)[[:space:]]*\[[[:space:]]*[0-9.]*[[:space:]]*\].*/\1/p' | sed 's/[[:space:]]*$//')
        series_index=$(printf '%s' "$entry_summary" | sed -n 's/.*[Ss][Ee][Rr][Ii][Ee][Ss]:[[:space:]]*[^[]*[[:space:]]*\[[[:space:]]*\([0-9.]*\)[[:space:]]*\].*/\1/p')
    fi
    series_index=$(printf '%s' "$series_index" | sed 's/\.0$//')
    nav=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*rel="subsection"[^>]*href="\([^"]*\)"[^>]*>.*|\1|p')
    [ -z "$nav" ] && nav=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*href="\([^"]*\)"[^>]*rel="subsection"[^>]*>.*|\1|p')
    [ -z "$nav" ] && nav=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*type="application/atom+xml[^\"]*"[^>]*href="\([^"]*\)"[^>]*>.*|\1|p')
    [ -z "$nav" ] && nav=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*href="\([^"]*\)"[^>]*type="application/atom+xml[^\"]*"[^>]*>.*|\1|p')
    book=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*rel="http://opds-spec.org/acquisition[^\"]*"[^>]*href="\([^"]*\)"[^>]*>.*|\1|p')
    [ -z "$book" ] && book=$(printf '%s' "$entry" | sed -n 's|.*<link[^>]*href="\([^"]*\)"[^>]*rel="http://opds-spec.org/acquisition[^\"]*"[^>]*>.*|\1|p')
    if [ -n "$book" ]; then
        target="/cgi-bin/opds?action=details&amp;entry=$entry_index&amp;url=$(url_encode "$current_url")"
    elif [ -n "$nav" ]; then
        nav=$(resolve_url "$nav" "$current_url")
        target="/cgi-bin/opds?url=$(url_encode "$nav")"
    else
        continue
    fi
    if [ -n "$book" ]; then
        acquisition_tags=$(printf '%s' "$entry" | sed 's|<link|\n<link|g' | grep 'opds-spec.org/acquisition')
        entry_links=$(printf '%s' "$entry" | sed 's|<link|\n<link|g')
        batch_thumbnail_tag=$(printf '%s\n' "$entry_links" | grep 'opds-spec.org/image/thumbnail' | head -n 1)
        batch_cover_tag=$(printf '%s\n' "$entry_links" | grep 'opds-spec.org/cover\|opds-spec.org/image"' | grep -v 'image/thumbnail' | head -n 1)
        [ -n "$batch_cover_tag" ] || batch_cover_tag=$batch_thumbnail_tag
        [ -n "$batch_thumbnail_tag" ] || batch_thumbnail_tag=$batch_cover_tag
        batch_cover=$(printf '%s' "$batch_cover_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
        batch_thumbnail=$(printf '%s' "$batch_thumbnail_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
        [ -n "$batch_cover" ] && batch_cover=$(resolve_url "$batch_cover" "$current_url")
        [ -n "$batch_thumbnail" ] && batch_thumbnail=$(resolve_url "$batch_thumbnail" "$current_url")
        case "$PREFERRED_FORMAT" in
            kepub) preferred_pattern='application/x-kobo-epub+zip\|application/vnd.kobo.epub+zip\|\.kepub' ;;
            epub) preferred_pattern='application/epub+zip' ;;
            pdf) preferred_pattern='application/pdf' ;;
            cbz) preferred_pattern='application/x-cbz\|application/vnd.comicbook+zip' ;;
            cbr) preferred_pattern='application/x-cbr' ;;
        esac
        acquisition_tag=$(printf '%s\n' "$acquisition_tags" | grep -i "$preferred_pattern" | head -n 1)
        [ -n "$acquisition_tag" ] || acquisition_tag=$(printf '%s\n' "$acquisition_tags" | grep 'application/epub+zip' | head -n 1)
        [ -n "$acquisition_tag" ] || acquisition_tag=$(printf '%s\n' "$acquisition_tags" | head -n 1)
        batch_url=$(printf '%s' "$acquisition_tag" | sed -n 's|.*href="\([^"]*\)".*|\1|p')
        batch_mime=$(printf '%s' "$acquisition_tag" | sed -n 's|.*type="\([^"]*\)".*|\1|p')
        batch_url=$(resolve_url "$batch_url" "$current_url")
        case "$batch_mime" in
            application/x-kobo-epub+zip|application/vnd.kobo.epub+zip) batch_extension=kepub.epub ;;
            application/pdf) batch_extension=pdf ;;
            application/x-cbz|application/vnd.comicbook+zip) batch_extension=cbz ;;
            application/x-cbr) batch_extension=cbr ;;
            *) batch_extension=epub ;;
        esac
        batch_name="${title}.${batch_extension}"
        printf '<div class="select-item"><input class="book-select" type="checkbox" name="pick_%s" value="1" aria-label="%s" onchange="saveBatchSelection()"><input type="hidden" name="url_%s" value="%s"><input type="hidden" name="name_%s" value="%s"><input type="hidden" name="title_%s" value="%s"><input type="hidden" name="author_%s" value="%s"><input type="hidden" name="series_%s" value="%s"><input type="hidden" name="series_index_%s" value="%s"><input type="hidden" name="cover_%s" value="%s"><input type="hidden" name="thumbnail_%s" value="%s">' "$entry_index" "$(printf '%s' "$title" | html_escape)" "$entry_index" "$(printf '%s' "$batch_url" | html_escape)" "$entry_index" "$(printf '%s' "$batch_name" | html_escape)" "$entry_index" "$(printf '%s' "$title" | html_escape)" "$entry_index" "$(printf '%s' "$author" | html_escape)" "$entry_index" "$(printf '%s' "$series" | html_escape)" "$entry_index" "$(printf '%s' "$series_index" | html_escape)" "$entry_index" "$(printf '%s' "$batch_cover" | html_escape)" "$entry_index" "$(printf '%s' "$batch_thumbnail" | html_escape)"
    fi
    printf '<a class="item" href="%s"><span>%s</span>' "$target" "$(printf '%s' "$title" | html_escape)"
    if [ -n "$author" ] || [ -n "$series" ]; then
        printf '<div class="meta">'
        if [ -n "$series" ]; then
            printf '<span class="meta-series">%s: %s' "$(msg series)" "$(printf '%s' "$series" | html_escape)"
            [ -n "$series_index" ] && printf ' · #%s' "$(printf '%s' "$series_index" | html_escape)"
            printf '</span>'
        fi
        [ -n "$author" ] && printf '<span class="meta-author">%s</span>' "$(printf '%s' "$author" | html_escape)"
        printf '</div>'
    fi
    printf '</a>'
    [ -n "$book" ] && printf '</div>'
    printf '\n'
done
printf '</form>'
printf '<script>restoreBatchSelection()</script>'
printf '</body></html>\n'
