-- --------------------------------------------------------------------------------------------------------------
-- SCHEMA FOR WORKFLOW OUTPUT DATA DAO
-- --------------------------------------------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS output_data (
  external_job_id   TEXT NOT NULL COLLATE "C",
  external_job_type TEXT NOT NULL COLLATE "C",
  workflow_id       TEXT NOT NULL,
  payload           JSON NOT NULL,
  create_ts         TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
  modify_ts         TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (external_job_type,external_job_id)
);

-- --------------------------------------------------------------------------------------------------------------
-- SCHEMA FOR MAESTRO INSTANCE ACTION RELATED DAOs
-- --------------------------------------------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS maestro_step_instance_action (   -- table to record step instance actions
  workflow_id               TEXT NOT NULL COLLATE "C",
  workflow_instance_id      INT8 NOT NULL CHECK (workflow_instance_id > 0),
  workflow_run_id           INT8 NOT NULL CHECK (workflow_run_id > 0),
  step_id                   TEXT NOT NULL COLLATE "C",
  payload                   JSON NOT NULL,
  create_ts                 TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (workflow_id, workflow_instance_id, workflow_run_id, step_id)
);
CREATE TABLE IF NOT EXISTS maestro_step_breakpoint (
     system_generated          BOOL NOT NULL, -- True when inserting paused step attempt due to breakpoint. False to denote explicit user actions.
     workflow_id               TEXT NOT NULL COLLATE "C",
     version                   INT8 NOT NULL,
     instance_id               INT8 NOT NULL,
     run_id                    INT8 NOT NULL,
     step_id                   TEXT NOT NULL COLLATE "C",
     step_attempt_id           INT8 NOT NULL,
     create_ts                 TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
     created_by                JSONB,
     PRIMARY KEY (workflow_id, step_id, system_generated, version, instance_id, run_id, step_attempt_id)
    );


#!/usr/bin/env bash
set -Eeuo pipefail

: "${KEY:=""}"
: "${HOST:=""}"
: "${WIDTH:=""}"
: "${HEIGHT:=""}"
: "${VERIFY:=""}"
: "${DOMAIN:=""}"
: "${REGION:=""}"
: "${EDITION:=""}"
: "${MANUAL:=""}"
: "${REMOVE:=""}"
: "${REBUILD:=""}"
: "${VERSION:=""}"
: "${COMMAND:=""}"
: "${DETECTED:=""}"
: "${KEYBOARD:=""}"
: "${LANGUAGE:=""}"
: "${USERNAME:=""}"
: "${PASSWORD:=""}"
: "${SHORTCUT:=""}"
: "${DOMAIN_OU:=""}"
: "${WORKGROUP:=""}"
: "${AUTOLOGIN:=""}"

# Sanitize variables
KEY=$(strip "$KEY")
HOST=$(strip "$HOST")
WIDTH=$(strip "$WIDTH")
HEIGHT=$(strip "$HEIGHT")
DOMAIN=$(strip "$DOMAIN")
REGION=$(strip "$REGION")
EDITION=$(strip "$EDITION")
KEYBOARD=$(strip "$KEYBOARD")
LANGUAGE=$(strip "$LANGUAGE")
USERNAME=$(strip "$USERNAME")
DOMAIN_OU=$(strip "$DOMAIN_OU")
WORKGROUP=$(strip "$WORKGROUP")

EDITION_ORDER=(
  "-enterprise|enterprise|enterprise enterprise-*"
  "-ultimate|ultimate|ultimate ultimate-*"
  "|default|@default n pro pro-* professional professional-* business business-*"
  "-iot|iot|iot iot-* enterprise-iot enterprise-iot-*"
  "-ltsc|ltsc|ltsc ltsc-* enterprise-ltsc enterprise-ltsc-*"
  "-education|education|education education-* pro-education pro-education-*"
  "-home|home|home home-*"
  "-home-premium|home|home-premium home-premium-*"
  "-home-basic|home|home-basic home-basic-*"
  "-starter|starter|starter starter-*"
)

SERVER_EDITION_ORDER=(
  "|default|@default"
  "-datacenter|datacenter|datacenter datacenter-*"
  "-datacenter-azure|datacenter|datacenter-azure"
  "-enterprise|enterprise|enterprise enterprise-*"
  "-web|web|web web-*"
  "-foundation|foundation|foundation foundation-*"
  "-essentials|essentials|essentials essentials-*"
  "-standard-core|standard-core|standard-core standard-core-*"
  "-datacenter-core|datacenter-core|datacenter-core datacenter-core-*"
  "-datacenter-azure-core|datacenter-core|datacenter-azure-core"
  "-enterprise-core|enterprise-core|enterprise-core enterprise-core-*"
  "-web-core|web-core|web-core web-core-*"
  "-hv|hv|hv hv-*"
)

MIRRORS=3

parseVersion() {

  SUGGEST=""
  VERSION=$(strip "$VERSION")
  [ -z "$VERSION" ] && VERSION="win11"

  case "${VERSION,,}" in
    "11" | "11p" | "win11" | "pro11" | "win11p" | "windows11" | "windows 11" )
      VERSION="win11x64"
      ;;
    "11e" | "win11e" | "windows11e" | "windows 11e" )
      VERSION="win11x64-enterprise-eval"
      ;;
    "11l" | "11ltsc" | "ltsc11" | "win11l" | "win11-ltsc" | "win11x64-ltsc" )
      VERSION="win11x64-enterprise-ltsc-eval"
      ;;
    "11i" | "11iot" | "iot11" | "win11i" | "win11-iot" | "win11x64-iot" )
      VERSION="win11x64-enterprise-iot-eval"
      ;;
    "10" | "10p" | "win10" | "pro10" | "win10p" | "windows10" | "windows 10" )
      VERSION="win10x64"
      ;;
    "10e" | "win10e" | "windows10e" | "windows 10e" )
      VERSION="win10x64-enterprise-eval"
      ;;
    "10l" | "10ltsc" | "ltsc10" | "win10l" | "win10-ltsc" | "win10x64-ltsc" )
      VERSION="win10x64-enterprise-ltsc-eval"
      ;;
    "10i" | "10iot" | "iot10" | "win10i" | "win10-iot" | "win10x64-iot" )
      VERSION="win10x64-enterprise-iot-eval"
      ;;
    "8" | "8p" | "81" | "81p" | "pro8" | "8.1" | "win8" | "win8p" | "win81" | "win81p" | "windows 8" )
      VERSION="win81x64"
      ;;
    "8e" | "81e" | "8.1e" | "win8e" | "win81e" | "windows 8e" )
      VERSION="win81x64-enterprise-eval"
      ;;
    "7" | "win7" | "windows7" | "windows 7" )
      VERSION="win7x64"
      ;;
    "7u" | "win7u" | "windows7u" | "windows 7u" )
      VERSION="win7x64-ultimate"
      ;;
    "7e" | "win7e" | "windows7e" | "windows 7e" )
      VERSION="win7x64-enterprise"
      ;;
    "7x86" | "win7x86" | "win732" | "windows7x86" )
      VERSION="win7x86"
      ;;
    "7ux86" | "7u32" | "win7x86-ultimate" )
      VERSION="win7x86-ultimate"
      ;;
    "7ex86" | "7e32" | "win7x86-enterprise" )
      VERSION="win7x86-enterprise"
      ;;
    "vista" | "vs" | "6" | "winvista" | "windowsvista" | "windows vista" )
      VERSION="winvistax64"
      ;;
    "vistu" | "vu" | "6u" | "winvistu" )
      VERSION="winvistax64-ultimate"
      ;;
    "viste" | "ve" | "6e" | "winviste" )
      VERSION="winvistax64-enterprise"
      ;;
    "vistax86" | "vista32" | "6x86" | "winvistax86" | "windowsvistax86" )
      VERSION="winvistax86"
      ;;
    "vux86" | "vu32" | "winvistax86-ultimate" )
      VERSION="winvistax86-ultimate"
      ;;
    "vex86" | "ve32" | "winvistax86-enterprise" )
      VERSION="winvistax86-enterprise"
      ;;
    "xp" | "xp32" | "xpx86" | "5" | "5x86" | "winxp" | "winxp86" | "windowsxp" | "windows xp" )
      VERSION="winxpx86"
      ;;
    "xp64" | "xpx64" | "5x64" | "winxp64" | "winxpx64" | "windowsxp64" | "windowsxpx64" )
      VERSION="winxpx64"
      ;;
    "2k" | "2000" | "win2k" | "win2000" | "windows2k" | "windows2000" )
      VERSION="win2kx86"
      ;;
    "25" | "2025" | "win25" | "win2025" | "windows2025" | "windows 2025" )
      VERSION="win2025-eval"
      ;;
    "22" | "2022" | "win22" | "win2022" | "windows2022" | "windows 2022" )
      VERSION="win2022-eval"
      ;;
    "19" | "2019" | "win19" | "win2019" | "windows2019" | "windows 2019" )
      VERSION="win2019-eval"
      ;;
    "16" | "2016" | "win16" | "win2016" | "windows2016" | "windows 2016" )
      VERSION="win2016-eval"
      ;;
    "hv" | "hyperv" | "hyper v" | "hyper-v" | "19hv" | "2019hv" | "win2019hv" )
      VERSION="win2019-hv"
      ;;
    "2012" | "2012r2" | "win2012" | "win2012r2" | "windows2012" | "windows 2012" )
      VERSION="win2012r2-eval"
      ;;
    "2008" | "2008r2" | "win2008" | "win2008r2" | "windows2008" | "windows 2008" )
      VERSION="win2008r2"
      ;;
    "2003" | "2003r2" | "win2003" | "win2003r2" | "windows2003" | "windows 2003" )
      VERSION="win2003r2"
      ;;
    "core11" | "core 11" )
      VERSION="core11"
      ;;
    "tiny11" | "tiny 11" )
      VERSION="tiny11"
      ;;
    "tiny10" | "tiny 10" )
      VERSION="tiny10"
      ;;
  esac

  SUGGEST=$(getSuggestedVersion "$VERSION")

  return 0
}

getSuggestedVersion() {

  local id="${1,,}"

  [[ "$id" == http* ]] && return 0

  case "$id" in
    "win10x64" | "win11x64" )
      echo "$id"
      ;;
    "win7x64" | "win7x86" | "winvistax64" | "winvistax86" )
      echo "$id-ultimate"
      ;;
    "tiny10" )
      echo "win10x64-ltsc"
      ;;
    *"-enterprise-ltsc-eval" )
      echo "${id%-enterprise-ltsc-eval}-ltsc"
      ;;
    *"-enterprise-iot-eval" )
      echo "${id%-enterprise-iot-eval}-iot"
      ;;
    *"-enterprise-ltsc" )
      echo "${id%-enterprise-ltsc}-ltsc"
      ;;
    *"-enterprise-iot" )
      echo "${id%-enterprise-iot}-iot"
      ;;
    *"-eval" )
      echo "${id%-eval}"
      ;;
  esac

  return 0
}

getLanguage() {

  local source="$1"
  local input="${1,,}"
  local ret="$2"
  local id="$source"
  local lang=""
  local desc=""
  local short=""
  local culture=""

  case "$input" in
    "ar" | "ar-"* | "arabic" | "arab" )
      [[ "$input" == "arabic" || "$input" == "arab" ]] && id="ar"
      short="ar"
      lang="Arabic"
      culture="ar-SA" ;;
    "bg" | "bg-"* | "bulgarian" | "bu" )
      [[ "$input" == "bulgarian" || "$input" == "bu" ]] && id="bg"
      short="bg"
      lang="Bulgarian"
      culture="bg-BG" ;;
    "cs" | "cs-"* | "cz" | "cz-"* | "czech" | "cesky" )
      [[ "$input" == "cz" || "$input" == "czech" || "$input" == "cesky" ]] && id="cs"
      short="cs"
      lang="Czech"
      culture="cs-CZ" ;;
    "da" | "da-"* | "dk" | "dk-"* | "danish" | "danske" )
      [[ "$input" == "dk" || "$input" == "danish" || "$input" == "danske" ]] && id="da"
      short="da"
      lang="Danish"
      culture="da-DK" ;;
    "de" | "de-"* | "german" | "deutsch" )
      [[ "$input" == "german" || "$input" == "deutsch" ]] && id="de"
      short="de"
      lang="German"
      culture="de-DE" ;;
    "el" | "el-"* | "gr" | "gr-"* | "greek" )
      [[ "$input" == "gr" || "$input" == "greek" ]] && id="el"
      short="el"
      lang="Greek"
      culture="el-GR" ;;
    "gb" | "en-gb" | "british" )
      [[ "$input" == "gb" || "$input" == "british" ]] && id="en-gb"
      short="en-gb"
      lang="English International"
      desc="English"
      culture="en-GB" ;;
    "en" | "en-"* | "english" )
      [[ "$input" == "english" ]] && id="en"
      short="en"
      lang="English"
      culture="en-US" ;;
    "mx" | "es-mx" )
      short="mx"
      lang="Spanish (Mexico)"
      desc="Spanish"
      culture="es-MX" ;;
    "es" | "es-"* | "spanish" | "espanol" | "español" )
      [[ "$input" == "spanish" || "$input" == "espanol" || "$input" == "español" ]] && id="es"
      short="es"
      lang="Spanish"
      culture="es-ES" ;;
    "et" | "et-"* | "estonian" | "eesti" )
      [[ "$input" == "estonian" || "$input" == "eesti" ]] && id="et"
      short="et"
      lang="Estonian"
      culture="et-EE" ;;
    "fi" | "fi-"* | "finnish" | "suomi" )
      [[ "$input" == "finnish" || "$input" == "suomi" ]] && id="fi"
      short="fi"
      lang="Finnish"
      culture="fi-FI" ;;
    "ca" | "fr-ca" )
      short="ca"
      lang="French Canadian"
      desc="French"
      culture="fr-CA" ;;
    "fr" | "fr-"* | "french" | "français" | "francais" )
      [[ "$input" == "french" || "$input" == "français" || "$input" == "francais" ]] && id="fr"
      short="fr"
      lang="French"
      culture="fr-FR" ;;
    "he" | "he-"* | "il" | "il-"* | "hebrew" )
      [[ "$input" == "il" || "$input" == "hebrew" ]] && id="he"
      short="he"
      lang="Hebrew"
      culture="he-IL" ;;
    "hr" | "hr-"* | "cr" | "cr-"* | "croatian" | "hrvatski" )
      [[ "$input" == "cr" || "$input" == "croatian" || "$input" == "hrvatski" ]] && id="hr"
      short="hr"
      lang="Croatian"
      culture="hr-HR" ;;
    "hu" | "hu-"* | "hungarian" | "magyar" )
      [[ "$input" == "hungarian" || "$input" == "magyar" ]] && id="hu"
      short="hu"
      lang="Hungarian"
      culture="hu-HU" ;;
    "it" | "it-"* | "italian" | "italiano" )
      [[ "$input" == "italian" || "$input" == "italiano" ]] && id="it"
      short="it"
      lang="Italian"
      culture="it-IT" ;;
    "ja" | "ja-"* | "jp" | "jp-"* | "japanese" )
      [[ "$input" == "jp" || "$input" == "japanese" ]] && id="ja"
      short="ja"
      lang="Japanese"
      culture="ja-JP" ;;
    "ko" | "ko-"* | "kr" | "kr-"* | "korean" )
      [[ "$input" == "kr" || "$input" == "korean" ]] && id="ko"
      short="ko"
      lang="Korean"
      culture="ko-KR" ;;
    "lt" | "lt-"* | "lithuanian" | "lietuvos" )
      [[ "$input" == "lithuanian" || "$input" == "lietuvos" ]] && id="lt"
      short="lt"
      lang="Lithuanian"
      culture="lt-LT" ;;
    "lv" | "lv-"* | "latvian" | "latvijas" )
      [[ "$input" == "latvian" || "$input" == "latvijas" ]] && id="lv"
      short="lv"
      lang="Latvian"
      culture="lv-LV" ;;
    "nb" | "nb-"* | "nn" | "nn-"* | "no" | "no-"* | "norwegian" | "norsk" )
      [[ "$input" == "nb" || "$input" == "no" || "$input" == "norwegian" || "$input" == "norsk" ]] && id="nn"
      short="no"
      lang="Norwegian"
      culture="nb-NO" ;;
    "nl" | "nl-"* | "dutch" | "nederlands" )
      [[ "$input" == "dutch" || "$input" == "nederlands" ]] && id="nl"
      short="nl"
      lang="Dutch"
      culture="nl-NL" ;;
    "pl" | "pl-"* | "polish" | "polski" )
      [[ "$input" == "polish" || "$input" == "polski" ]] && id="pl"
      short="pl"
      lang="Polish"
      culture="pl-PL" ;;
    "br" | "pt" | "pt-br" | "portuguese" | "português" | "portugues" )
      [[ "$input" != "pt-br" ]] && id="pt-br"
      short="pt"
      lang="Brazilian Portuguese"
      desc="Portuguese"
      culture="pt-BR" ;;
    "pt-"* )
      short="pp"
      lang="Portuguese"
      culture="pt-BR" ;;
    "ro" | "ro-"* | "romanian" | "română" | "romana" )
      [[ "$input" == "romanian" || "$input" == "română" || "$input" == "romana" ]] && id="ro"
      short="ro"
      lang="Romanian"
      culture="ro-RO" ;;
    "ru" | "ru-"* | "russian" | "ruski" )
      [[ "$input" == "russian" || "$input" == "ruski" ]] && id="ru"
      short="ru"
      lang="Russian"
      culture="ru-RU" ;;
    "sk" | "sk-"* | "slovak" | "slovenský" | "slovensky" )
      [[ "$input" == "slovak" || "$input" == "slovenský" || "$input" == "slovensky" ]] && id="sk"
      short="sk"
      lang="Slovak"
      culture="sk-SK" ;;
    "sl" | "sl-"* | "si" | "si-"* | "slovenian" | "slovenski" )
      [[ "$input" == "si" || "$input" == "slovenian" || "$input" == "slovenski" ]] && id="sl"
      short="sl"
      lang="Slovenian"
      culture="sl-SI" ;;
    "sr" | "sr-"* | "serbian" | "serbian latin" )
      [[ "$input" == "serbian" || "$input" == "serbian latin" ]] && id="sr"
      short="sr"
      lang="Serbian Latin"
      desc="Serbian"
      culture="sr-Latn-RS" ;;
    "sv" | "sv-"* | "se" | "se-"* | "swedish" | "svenska" )
      [[ "$input" == "se" || "$input" == "swedish" || "$input" == "svenska" ]] && id="sv"
      short="sv"
      lang="Swedish"
      culture="sv-SE" ;;
    "th" | "th-"* | "thai" )
      [[ "$input" == "thai" ]] && id="th"
      short="th"
      lang="Thai"
      culture="th-TH" ;;
    "tr" | "tr-"* | "turkish" | "türk" | "turk" )
      [[ "$input" == "turkish" || "$input" == "türk" || "$input" == "turk" ]] && id="tr"
      short="tr"
      lang="Turkish"
      culture="tr-TR" ;;
    "ua" | "ua-"* | "uk" | "uk-"* | "ukrainian" )
      [[ "$input" == "ua" || "$input" == "ukrainian" ]] && id="uk"
      short="uk"
      lang="Ukrainian"
      culture="uk-UA" ;;
    "hk" | "zh-hk" | "cn-hk" )
      short="hk"
      lang="Chinese (Traditional)"
      desc="Chinese HK"
      culture="zh-TW" ;;
    "tw" | "zh-tw" | "cn-tw" )
      short="tw"
      lang="Chinese (Traditional)"
      desc="Chinese TW"
      culture="zh-TW" ;;
    "zh" | "zh-"* | "cn" | "cn-"* | "chinese" )
      [[ "$input" == "cn" || "$input" == "chinese" ]] && id="zh"
      short="cn"
      lang="Chinese (Simplified)"
      desc="Chinese"
      culture="zh-CN" ;;
  esac

  [ -z "$lang" ] && return 0
  [ -z "$desc" ] && desc="$lang"

  case "${ret,,}" in
    "id" ) echo "$id" ;;
    "desc" ) echo "$desc" ;;
    "name" ) echo "$lang" ;;
    "code" ) echo "$short" ;;
    "culture" ) echo "$culture" ;;
    * ) echo "$desc";;
  esac

  return 0
}

parseLanguage() {

  REGION="${REGION//_/-}"
  KEYBOARD="${KEYBOARD//_/-}"
  LANGUAGE="${LANGUAGE//_/-}"

  [ -z "$LANGUAGE" ] && LANGUAGE="en"

  local id
  id=$(getLanguage "$LANGUAGE" "id")

  if [ -z "$id" ]; then
    error "Invalid LANGUAGE specified, value \"$LANGUAGE\" is not recognized!"
    return 1
  fi

  LANGUAGE="$id"
  return 0
}

printVersion() {

  local id="$1"
  local desc="$2"

  case "${id,,}" in
    "tiny11"* ) desc="Tiny 11" ;;
    "tiny10"* ) desc="Tiny 10" ;;
    "core11"* ) desc="Core 11" ;;
    "win7"* ) desc="Windows 7" ;;
    "win8"* ) desc="Windows 8" ;;
    "win10"* ) desc="Windows 10" ;;
    "win11"* ) desc="Windows 11" ;;
    "winxp"* ) desc="Windows XP" ;;
    "win9x"* ) desc="Windows ME" ;;
    "win98"* ) desc="Windows 98" ;;
    "win95"* ) desc="Windows 95" ;;
    "win2k"* ) desc="Windows 2000" ;;
    "winvista"* ) desc="Windows Vista" ;;
    "win2019-hv"* ) desc="Hyper-V Server" ;;
    "win2003"* ) desc="Windows Server 2003" ;;
    "win2008"* ) desc="Windows Server 2008" ;;
    "win2012"* ) desc="Windows Server 2012" ;;
    "win2016"* ) desc="Windows Server 2016" ;;
    "win2019"* ) desc="Windows Server 2019" ;;
    "win2022"* ) desc="Windows Server 2022" ;;
    "win2025"* ) desc="Windows Server 2025" ;;
  esac

  if [ -z "$desc" ]; then
    desc="Windows"
    [[ "${PLATFORM,,}" != "x64" ]] && desc+=" for ${PLATFORM}"
  fi

  echo "$desc"
  return 0
}

printVariant() {

  local id="$1"
  local desc="$2"
  local show_eval="${3:-N}"

  desc=$(printVersion "$id" "$desc") || return 1

  case "${id,,}" in
    *"-iot" | *"-iot-eval" )
      desc+=" IoT"
      ;;
    *"-ltsc" | *"-ltsc-eval" )
      desc+=" LTSC"
      ;;
    *"-enterprise" | *"-enterprise-eval" )
      desc+=" Enterprise"
      ;;
  esac

  if enabled "$show_eval" && [[ "${id,,}" == *"-eval" ]]; then
    desc+=" (Evaluation)"
  fi

  echo "$desc"
  return 0
}

formatEdition() {

  local edition="${1//-/ }"
  local result="" word

  for word in $edition; do
    if [ "$word" == "for" ]; then
      word="for"
    elif [ "${#word}" -eq 1 ]; then
      word="${word^^}"
    else
      word="${word^}"
    fi

    result+="${result:+ }$word"
  done

  echo "$result"
  return 0
}

printEdition() {

  local id="$1"
  local desc="$2"
  local show_eval="${3:-N}"
  local normalized="${id,,}"
  local result edition="" suffix=""

  result=$(printVersion "$id" "x")
  [[ "$result" == "x" ]] && echo "$desc" && return 0

  normalized="${normalized%-eval}"

  case "$normalized" in
    "winvista"* | "win7"* | "win8"* | "win10"* | "win11"* )
      [[ "$normalized" == *"-"* ]] && suffix="${normalized#*-}"

      case "$suffix" in
        "" )
          case "$normalized" in
            "winvista"* ) edition="Business" ;;
            "win7"* ) edition="Professional" ;;
            * ) edition="Pro" ;;
          esac
          ;;
        "home" )
          edition="Home"
          ;;
        "starter" )
          edition="Starter"
          ;;
        "ultimate" )
          edition="Ultimate"
          ;;
        "enterprise" )
          edition="Enterprise"
          ;;
        "education" )
          edition="Education"
          ;;
        "n" )
          case "$normalized" in
            "win7"* ) edition="Professional N" ;;
            * ) edition="Pro N" ;;
          esac
          ;;
        "iot" | "enterprise-iot" )
          edition="IoT Enterprise LTSC"
          ;;
        "ltsc" | "enterprise-ltsc" )
          edition="Enterprise LTSC"
          ;;
        * )
          edition=$(formatEdition "$suffix")
          ;;
      esac
      ;;
    "winxp"* )
      edition="Professional"
      ;;
    "win2019-hv"* )
      edition="2019"
      ;;
    "win20"* )
      [[ "$normalized" == *"-"* ]] && suffix="${normalized#*-}"

      if [ -n "$suffix" ]; then
        edition=$(formatEdition "$suffix")
      else
        case "${EDITION^^}" in
          *"DATACENTER"* ) edition="Datacenter" ;;
          "CORE" | "STANDARDCORE" ) edition="Core" ;;
          * ) edition="Standard" ;;
        esac
      fi
      ;;
  esac

  [ -n "$edition" ] && result+=" $edition"

  if enabled "$show_eval" && [[ "${id,,}" == *"-eval" ]]; then
    result+=" (Evaluation)"
  fi

  echo "$result"
  return 0
}

fromFile() {

  local id=""
  local desc="$1"
  local file="${1,,}"
  local arch="${PLATFORM,,}"

  file="${file//-/_}"
  file="${file// /_}"

  case "$file" in
    *"_x64_"* | *"_x64."*)
      arch="x64" ;;
    *"_x86_"* | *"_x86."*)
      arch="x86" ;;
    *"_arm64_"* | *"_arm64."*)
      arch="arm64" ;;
  esac

  local add=""
  [[ "$arch" != "x64" ]] && add="$arch"

  case "$file" in
    "win7"* | "win_7"* | *"windows7"* | *"windows_7"* )
      id="win7${arch}" ;;
    "win8"* | "win_8"* | *"windows8"* | *"windows_8"* )
      id="win81${arch}" ;;
    "win10"*| "win_10"* | *"windows10"* | *"windows_10"* )
      id="win10${arch}" ;;
    "win11"* | "win_11"* | *"windows11"* | *"windows_11"* )
      id="win11${arch}" ;;
    *"winxp"* | *"win_xp"* | *"windowsxp"* | *"windows_xp"* )
      id="winxpx86" ;;
    *"winvista"* | *"win_vista"* | *"windowsvista"* | *"windows_vista"* )
      id="winvista${arch}" ;;
    "tiny11core"* | "tiny11_core"* | "tiny_11_core"* )
      id="core11" ;;
    "tiny11"* | "tiny_11"* )
      id="tiny11" ;;
    "tiny10"* | "tiny_10"* )
      id="tiny10" ;;
    *"_serverhypercore_"* )
      id="win2019${add}-hv" ;;
    *"server2025"* | *"server_2025"* )
      id="win2025${add}" ;;
    *"server2022"* | *"server_2022"* )
      id="win2022${add}" ;;
    *"server2019"* | *"server_2019"* )
      id="win2019${add}" ;;
    *"server2016"* | *"server_2016"* )
      id="win2016${add}" ;;
    *"server2012"* | *"server_2012"* )
      id="win2012r2${add}" ;;
    *"server2008"* | *"server_2008"* )
      id="win2008r2${add}" ;;
    *"server2003"* | *"server_2003"* )
      id="win2003r2${add}" ;;
  esac

  if [ -n "$id" ]; then
    desc=$(printVersion "$id" "$desc")
  fi

  echo "$desc"
  return 0
}

fromName() {

  local id=""
  local name="$1"
  local arch="$2"

  local add=""
  [[ "$arch" != "x64" ]] && add="$arch"

  case "${name,,}" in
    *"windows 7"* ) id="win7${arch}" ;;
    *"windows 8"* ) id="win81${arch}" ;;
    *"windows 10"* ) id="win10${arch}" ;;
    *"optimum 10"* ) id="win10${arch}" ;;
    *"windows 11"* ) id="win11${arch}" ;;
    *"optimum 11"* ) id="win11${arch}" ;;
    *"windows vista"* ) id="winvista${arch}" ;;
    *"server 2025"* ) id="win2025${add}" ;;
    *"server 2022"* ) id="win2022${add}" ;;
    *"server 2019"* ) id="win2019${add}" ;;
    *"server 2016"* ) id="win2016${add}" ;;
    *"server 2012"* ) id="win2012r2${add}" ;;
    *"server 2008"* ) id="win2008r2${add}" ;;
    *"server 2003"* ) id="win2003r2${add}" ;;
    *"hyper-v server"* ) id="win2019${add}" ;;
  esac

  echo "$id"
  return 0
}

normalizeEdition() {

  local source="${1,,}"
  local edition

  source="${source//evaluation/}"

  source=$(printf '%s' "$source" |
    uconv -x 'Any-Latin; Latin-ASCII' 2>/dev/null) || return 1

  edition=$(sed -E \
    -e 's/[^a-z0-9]+/-/g' \
    -e 's/^-+//' \
    -e 's/-+$//' \
    <<< "$source")

  echo "$edition"
  return 0
}

normalizeEditionID() {

  local edition
  local id="$2"

  edition=$(normalizeEdition "$1")

  case "$edition" in
    "pro" | "professional" | "business" )
      edition="" ;;
    "pro-n" | "professional-n" )
      edition="n" ;;
  esac

  case "${id,,}" in
    "win10"* | "win11"* )
      case "$edition" in
        "iot-enterprise-ltsc" | \
        "iot-enterprise-ltsc-"[0-9][0-9][0-9][0-9] )
          edition="iot" ;;
        "enterprise-ltsc" | \
        "enterprise-ltsc-"[0-9][0-9][0-9][0-9] )
          edition="ltsc" ;;
      esac
      ;;
  esac

  echo "$edition"
  return 0
}

getEditionID() {

  local name="${1,,}"
  local id="${2,,}"
  local edition

  case "$id" in
    "winvista"* )
      edition="${name#*vista}" ;;
    "win7"* )
      edition="${name#*7}" ;;
    "win8"* )
      if [[ "$name" == *"8.1"* ]]; then
        edition="${name#*8.1}"
      else
        edition="${name#*8}"
      fi ;;
    "win10"* )
      edition="${name#*10}" ;;
    "win11"* )
      edition="${name#*11}" ;;
    * ) return 1 ;;
  esac

  edition=$(normalizeEditionID "$edition" "$id")

  echo "$edition"
  return 0
}

normalizeServerEdition() {

  local edition

  edition=$(normalizeEdition "$1") || return 1
  edition="${edition#r2-}"

  case "$edition" in
    "core" | "core-installation" | "server-core-installation" )
      edition="standard-core"
      ;;
    "desktop-experience" | "server-with-a-gui" | "full-installation" )
      edition="standard"
      ;;
    *"-server-core-installation" )
      edition="${edition%-server-core-installation}-core"
      ;;
    *"-core-installation" )
      edition="${edition%-core-installation}-core"
      ;;
    *"-desktop-experience" )
      edition="${edition%-desktop-experience}"
      ;;
    *"-server-with-a-gui" )
      edition="${edition%-server-with-a-gui}"
      ;;
    *"-full-installation" )
      edition="${edition%-full-installation}"
      ;;
  esac

  edition="${edition#server-}"
  edition="${edition#server}"

  [ "$edition" == "core" ] && edition="standard-core"

  echo "$edition"
  return 0
}

normalizeServerEditionID() {

  local edition

  edition=$(normalizeServerEdition "$1") || return 1

  case "$edition" in
    "" | "standard" | "serverstandard" ) edition="" ;;
    "core" | "standard-core" | "standardcore" | "serverstandardcore" ) edition="standard-core" ;;
    "datacenter" | "serverdatacenter" ) edition="datacenter" ;;
    "datacenter-core" | "datacentercore" | "serverdatacentercore" ) edition="datacenter-core" ;;
    "datacenter-azure" | "datacenter-azure-edition" | "datacenterazureedition" | \
    "serverdatacenterazureedition" | "serverturbine" ) edition="datacenter-azure" ;;
    "datacenter-azure-core" | "datacenter-azure-edition-core" | \
    "datacenterazureeditioncore" | "serverdatacenterazureeditioncore" | \
    "serverturbinecore" ) edition="datacenter-azure-core" ;;
    "enterprise" | "serverenterprise" ) edition="enterprise" ;;
    "enterprise-core" | "enterprisecore" | "serverenterprisecore" ) edition="enterprise-core" ;;
    "web" | "serverweb" ) edition="web" ;;
    "web-core" | "webcore" | "serverwebcore" ) edition="web-core" ;;
    "foundation" | "serverfoundation" ) edition="foundation" ;;
    "essentials" | "serveressentials" ) edition="essentials" ;;
    # Keep unrecognized internal edition IDs deterministic and unique.
    # Known aliases above only provide stable, friendlier public names.
    * ) : ;;
  esac

  echo "$edition"
  return 0
}

getServerEditionID() {

  local name="${1,,}"
  local id="${2,,}"
  local edition

  case "$id" in
    "win2025"* ) edition="${name#*server 2025}" ;;
    "win2022"* ) edition="${name#*server 2022}" ;;
    "win2019"* ) edition="${name#*server 2019}" ;;
    "win2016"* ) edition="${name#*server 2016}" ;;
    "win2012"* ) edition="${name#*server 2012}" ;;
    "win2008"* ) edition="${name#*server 2008}" ;;
    "win2003"* ) edition="${name#*server 2003}" ;;
    * ) return 1 ;;
  esac

  edition=$(normalizeServerEditionID "$edition")

  echo "$edition"
  return 0
}

getVersion() {

  local id edition
  local name="$1"
  local arch="$2"
  local evaluation=""

  id=$(fromName "$name" "$arch")
  [[ "${name,,}" == *"evaluation"* ]] && evaluation="-eval"

  case "${id,,}" in
    "winvista"* | "win7"* | "win8"* | "win10"* | "win11"* )
      if edition=$(getEditionID "$name" "$id"); then
        [ -n "$edition" ] && id+="-$edition"
        [ -n "$evaluation" ] && id+="$evaluation"
      fi
      ;;
    "win2025"* | "win2022"* | "win2019"* | "win2016"* | \
    "win2012"* | "win2008"* | "win2003"* )
      if [[ "${name,,}" == *"hyper-v server"* ]]; then
        id+="-hv"
      elif edition=$(getServerEditionID "$name" "$id"); then
        [ -n "$edition" ] && id+="-$edition"
        [ -n "$evaluation" ] && id+="$evaluation"
      fi
      ;;
  esac

  echo "$id"
  return 0
}

switchEdition() {

  local -n id="$1"

  [[ "${id,,}" == *"-eval" ]] || return 1

  id="${id::-5}"

  if ! enabled "${DETECTED_ORG:-}"; then
    DETECTED="${SUGGEST:-$id}"
  fi

  return 0
}

getMido() {

  local id="$1"
  local lang="$2"
  local ret="$3"
  local url=""
  local sum=""
  local size=""

  [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0

  case "${id,,}" in
    "win11x64" )
      size=7736125440
      sum="d141f6030fed50f75e2b03e1eb2e53646c4b21e5386047cb860af5223f102a32"
      url="https://software-static.download.prss.microsoft.com/dbazure/888969d5-f34g-4e03-ac9d-1f9786c66749/26200.6584.250915-1905.25h2_ge_release_svc_refresh_CLIENT_CONSUMER_x64FRE_en-us.iso"
      ;;
    "win11x64-enterprise-eval" )
      size=7092807680
      sum="a61adeab895ef5a4db436e0a7011c92a2ff17bb0357f58b13bbc4062e535e7b9"
      url="https://software-static.download.prss.microsoft.com/dbazure/888969d5-f34g-4e03-ac9d-1f9786c66749/26200.6584.250915-1905.25h2_ge_release_svc_refresh_CLIENTENTERPRISEEVAL_OEMRET_x64FRE_en-us.iso"
      ;;
    "win11x64-enterprise-ltsc-eval" )
      size=5112850432
      sum="67cec5865eaa037a72ddc633a717a10a2bed50778862267223ddb9c60ef5da68"
      url="https://software-static.download.prss.microsoft.com/dbazure/888969d5-f34g-4e03-ac9d-1f9786c66749/26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_LTSC_EVAL_x64FRE_en-us.iso"
      ;;
    "win11x64-enterprise-iot-eval" )
      size=5060020224
      sum="2cee70bd183df42b92a2e0da08cc2bb7a2a9ce3a3841955a012c0f77aeb3cb29"
      url="https://software-static.download.prss.microsoft.com/dbazure/998969d5-f34g-4e03-ac9d-1f9786c66749/26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_IOT_LTSC_EVAL_x64FRE_en-us.iso"
      ;;
    "win10x64-enterprise-eval" )
      size=5550497792
      sum="ef7312733a9f5d7d51cfa04ac497671995674ca5e1058d5164d6028f0938d668"
      url="https://software-static.download.prss.microsoft.com/dbazure/988969d5-f34g-4e03-ac9d-1f9786c66750/19045.2006.220908-0225.22h2_release_svc_refresh_CLIENTENTERPRISEEVAL_OEMRET_x64FRE_en-us.iso"
      ;;
    "win10x64-enterprise-ltsc-eval" )
      size=4898582528
      sum="e4ab2e3535be5748252a8d5d57539a6e59be8d6726345ee10e7afd2cb89fefb5"
      url="https://software-download.microsoft.com/download/pr/19044.1288.211006-0501.21h2_release_svc_refresh_CLIENT_LTSC_EVAL_x64FRE_en-us.iso"
      ;;
    "win81x64-enterprise-eval" )
      size=3961473024
      sum="2dedd44c45646c74efc5a028f65336027e14a56f76686a4631cf94ffe37c72f2"
      url="https://download.microsoft.com/download/B/9/9/B999286E-0A47-406D-8B3D-5B5AD7373A4A/9600.17050.WINBLUE_REFRESH.140317-1640_X64FRE_ENTERPRISE_EVAL_EN-US-IR3_CENA_X64FREE_EN-US_DV9.ISO"
      ;;
    "win2025-eval" )
      size=8152356864
      sum="7b052573ba7894c9924e3e87ba732ccd354d18cb75a883efa9b900ea125bfd51"
      url="https://software-static.download.prss.microsoft.com/dbazure/998969d5-f34g-4e03-ac9d-1f9786c66749/26100.32230.260111-0550.lt_release_svc_refresh_SERVER_EVAL_x64FRE_en-us.iso"
      ;;
    "win2022-eval" )
      size=5044094976
      sum="3e4fa6d8507b554856fc9ca6079cc402df11a8b79344871669f0251535255325"
      url="https://software-static.download.prss.microsoft.com/sg/download/888969d5-f34g-4e03-ac9d-1f9786c66749/SERVER_EVAL_x64FRE_en-us.iso"
      ;;
    "win2019-eval" )
      size=5296713728
      sum="549bca46c055157291be6c22a3aaaed8330e78ef4382c99ee82c896426a1cee1"
      url="https://software-download.microsoft.com/download/pr/17763.737.190906-2324.rs5_release_svc_refresh_SERVER_EVAL_x64FRE_en-us_1.iso"
      ;;
    "win2019-hv" )
      size=3022784512
      sum="cb28984af65ba1085cd6ade5fdd3d9c75efe7618846513f9ad44f1397a409f85"
      url="https://software-download.microsoft.com/download/pr/17763.557.190612-0019.rs5_release_svc_refresh_SERVERHYPERCORE_OEM_x64FRE_en-us.ISO"
      ;;
    "win2016-eval" )
      size=6972221440
      sum="1ce702a578a3cb1ac3d14873980838590f06d5b7101c5daaccbac9d73f1fb50f"
      url="https://software-download.microsoft.com/download/pr/Windows_Server_2016_Datacenter_EVAL_en-us_14393_refresh.ISO"
      ;;
    "win2012r2-eval" )
      size=4542291968
      sum="6612b5b1f53e845aacdf96e974bb119a3d9b4dcb5b82e65804ab7e534dc7b4d5"
      url="https://download.microsoft.com/download/6/2/A/62A76ABB-9990-4EFC-A4FE-C7D698DAEB96/9600.17050.WINBLUE_REFRESH.140317-1640_X64FRE_SERVER_EVAL_EN-US-IR3_SSS_X64FREE_EN-US_DV9.ISO"
      ;;
    "win2008r2" | "win2008r2-eval" )
      size=3166840832
      sum="30832ad76ccfa4ce48ccb936edefe02079d42fb1da32201bf9e3a880c8ed6312"
      url="https://download.microsoft.com/download/4/1/D/41DEA7E0-B30D-4012-A1E3-F24DC03BA1BB/7601.17514.101119-1850_x64fre_server_eval_en-us-GRMSXEVAL_EN_DVD.iso"
      ;;
  esac

  case "${ret,,}" in
    "sum" ) echo "$sum" ;;
    "size" ) echo "$size" ;;
    *) echo "$url";;
  esac

  return 0
}

getLink1() {

  # Fallbacks for users who cannot connect to the Microsoft servers

  local id="$1"
  local lang="$2"
  local ret="$3"
  local url=""
  local sum=""
  local size=""
  local host="https://dl.bobpony.com/windows"

  [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0

  case "${id,,}" in
    "win11x64" | "win11x64-enterprise" )
      size=6927149056
      sum="f5ffe9313eebc6299fba9e6eeb2971007264e6c6be013073a89b5ae9bd85bfb3"
      url="11/en-us_windows_11_25h2_x64.iso"
      ;;
    "win11x64-ltsc" | "win11x64-enterprise-ltsc" )
      size=5144817664
      sum="4f59662a96fc1da48c1b415d6c369d08af55ddd64e8f1c84e0166d9e50405d7a"
      url="11/X23-81951_26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_ENTERPRISES_OEM_x64FRE_en-us.iso"
      ;;
    "win11x64-iot" | "win11x64-enterprise-iot" )
      size=5144817664
      sum="4f59662a96fc1da48c1b415d6c369d08af55ddd64e8f1c84e0166d9e50405d7a"
      url="11/X23-81951_26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_ENTERPRISES_OEM_x64FRE_en-us.iso"
      ;;
    "win10x64" | "win10x64-enterprise" )
      size=5723299840
      sum="316f718f21fc9b386d81dadd62dc60268a1cfd65b184ac6a052875a454c3431b"
      url="10/en-us_windows_10_22h2_x64.iso"
      ;;
    "win10x64-ltsc" | "win10x64-enterprise-ltsc" )
      size=4899461120
      sum="c90a6df8997bf49e56b9673982f3e80745058723a707aef8f22998ae6479597d"
      url="10/en-us_windows_10_enterprise_ltsc_2021_x64_dvd_d289cf96.iso"
      ;;
    "win10x64-iot" | "win10x64-enterprise-iot" )
      size=4851668992
      sum="a0334f31ea7a3e6932b9ad7206608248f0bd40698bfb8fc65f14fc5e4976c160"
      url="10/en-us_windows_10_iot_enterprise_ltsc_2021_x64_dvd_257ad90f.iso"
      ;;
    "win81x64" )
      size=4320526336
      sum="d8333cf427eb3318ff6ab755eb1dd9d433f0e2ae43745312c1cd23e83ca1ce51"
      url="8.x/8.1/en_windows_8.1_with_update_x64_dvd_6051480.iso"
      ;;
    "win81x64-enterprise" )
      size=4139163648
      sum="c3c604c03677504e8905090a8ce5bb1dde76b6fd58e10f32e3a25bef21b2abe1"
      url="8.x/8.1/en_windows_8.1_enterprise_with_update_x64_dvd_6054382.iso"
      ;;
    "win2025" )
      size=7571058688
      sum="d273d0a85565ffbc06a3d46313f619103e2830a3373306ddbb9a08b8824f509d"
      url="server/2025/en-us_windows_server_2025_updated_oct_2025_x64_dvd_6c0c5aa8.iso"
      ;;
    "win2022" )
      size=6023239680
      sum="5d6d91efa972cbdd6701d78db1dcf6a34c7024ca931c1718e7cb3d0c6dd54e88"
      url="server/2022/en-us_windows_server_2022_updated_oct_2025_x64_dvd_26e9af36.iso"
      ;;
    "win2019" )
      size=5575774208
      sum="0067afe7fdc4e61f677bd8c35a209082aa917df9c117527fc4b2b52a447e89bb"
      url="server/2019/en-us_windows_server_2019_updated_aug_2021_x64_dvd_a6431a28.iso"
      ;;
    "win2016" )
      size=6006587392
      sum="af06e5483c786c023123e325cea4775050324d9e1366f46850b515ae43f764be"
      url="server/2016/en_windows_server_2016_updated_feb_2018_x64_dvd_11636692.iso"
      ;;
    "win2012r2" )
      size=5397889024
      sum="f351e89eb88a96af4626ceb3450248b8573e3ed5924a4e19ea891e6003b62e4e"
      url="server/2012r2/en_windows_server_2012_r2_with_update_x64_dvd_6052708-004.iso"
      ;;
    "win2008r2" )
      size=3166584832
      sum="dfd9890881b7e832a927c38310fb415b7ea62ac5a896671f2ce2a111998f0df8"
      url="server/2008r2/en_windows_server_2008_r2_with_sp1_x64_dvd_617601-018.iso"
      ;;
    "win7x64" | "win7x64-ultimate" )
      size=3320836096
      sum="0b738b55a5ea388ad016535a5c8234daf2e5715a0638488ddd8a228a836055a1"
      url="7/en_windows_7_with_sp1_x64.iso"
      ;;
    "win7x64-enterprise" )
      size=3182604288
      sum="ee69f3e9b86ff973f632db8e01700c5724ef78420b175d25bae6ead90f6805a7"
      url="7/en_windows_7_enterprise_with_sp1_x64_dvd_u_677651.iso"
      ;;
    "win7x86" | "win7x86-ultimate" )
      size=2564411392
      sum="99f3369c90160816be07093dbb0ac053e0a84e52d6ed1395c92ae208ccdf67e5"
      url="7/en_windows_7_with_sp1_x86.iso"
      ;;
    "win7x86-enterprise" )
      size=2434502656
      sum="8bdd46ff8cb8b8de9c4aba02706629c8983c45e87da110e64e13be17c8434dad"
      url="7/en_windows_7_enterprise_with_sp1_x86_dvd_u_677710.iso"
      ;;
    "winvistax64" | "winvistax64-ultimate" )
      size=3861460992
      sum="edf9f947c5791469fd7d2d40a5dcce663efa754f91847aa1d28ed7f585675b78"
      url="vista/en_windows_vista_sp2_x64_dvd_342267.iso"
      ;;
    "winvistax86" | "winvistax86-ultimate" )
      size=3243413504
      sum="9c36fed4255bd05a8506b2da88f9aad73643395e155e609398aacd2b5276289c"
      url="vista/en_windows_vista_with_sp2_x86_dvd_342266.iso"
      ;;
    "win2003r2" )
      size=731650535
      sum="6b64bbae7eb00fd000cc887ffdc9f224d00c557daad7f756cfa373950b880dc8"
      url="server/2003r2/en_win_srv_2003_r2_standard_x64_with_sp2_cd1_cd2.zip"
      ;;
    "winxpx86" )
      size=617756672
      sum="62b6c91563bad6cd12a352aa018627c314cfc5162d8e9f8af0756a642e602a46"
      url="xp/professional/en_windows_xp_professional_with_service_pack_3_x86_cd_x14-80428.iso"
      ;;
    "win2kx86" )
      size=331701982
      sum="a93251b31f92316411bb48458a695d9051b13cdeba714c46f105012fdda45bf3"
      url="2000/5.00.2195.6717_x86fre_client-professional_retail_en-us.7z"
      ;;
  esac

  case "${ret,,}" in
    "sum" ) echo "$sum" ;;
    "size" ) echo "$size" ;;
    *) [ -n "$url" ] && echo "$host/$url";;
  esac

  return 0
}

getLink2() {

  local id="$1"
  local lang="$2"
  local ret="$3"
  local url=""
  local sum=""
  local size=""
  local host="https://files.dog/MSDN"

  [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0

  case "${id,,}" in
    "win81x64" )
      size=4320526336
      sum="d8333cf427eb3318ff6ab755eb1dd9d433f0e2ae43745312c1cd23e83ca1ce51"
      url="Windows%208.1%20with%20Update/en_windows_8.1_with_update_x64_dvd_6051480.iso"
      ;;
    "win81x64-enterprise" )
      size=4139163648
      sum="c3c604c03677504e8905090a8ce5bb1dde76b6fd58e10f32e3a25bef21b2abe1"
      url="Windows%208.1%20with%20Update/en_windows_8.1_enterprise_with_update_x64_dvd_6054382.iso"
      ;;
    "win2012r2" )
      size=5397889024
      sum="f351e89eb88a96af4626ceb3450248b8573e3ed5924a4e19ea891e6003b62e4e"
      url="Windows%20Server%202012%20R2%20with%20Update/en_windows_server_2012_r2_with_update_x64_dvd_6052708.iso"
      ;;
    "win2008r2" )
      size=3166584832
      sum="dfd9890881b7e832a927c38310fb415b7ea62ac5a896671f2ce2a111998f0df8"
      url="Windows%20Server%202008%20R2/en_windows_server_2008_r2_with_sp1_x64_dvd_617601.iso"
      ;;
    "win7x64" | "win7x64-ultimate" )
      size=3320903680
      sum="36f4fa2416d0982697ab106e3a72d2e120dbcdb6cc54fd3906d06120d0653808"
      url="Windows%207/en_windows_7_ultimate_with_sp1_x64_dvd_u_677332.iso"
      ;;
    "win7x64-enterprise" )
      size=3182604288
      sum="ee69f3e9b86ff973f632db8e01700c5724ef78420b175d25bae6ead90f6805a7"
      url="Windows%207/en_windows_7_enterprise_with_sp1_x64_dvd_u_677651.iso"
      ;;
    "win7x86" | "win7x86-ultimate" )
      size=2564476928
      sum="e2c009a66d63a742941f5087acae1aa438dcbe87010bddd53884b1af6b22c940"
      url="Windows%207/en_windows_7_ultimate_with_sp1_x86_dvd_u_677460.iso"
      ;;
    "win7x86-enterprise" )
      size=2434502656
      sum="8bdd46ff8cb8b8de9c4aba02706629c8983c45e87da110e64e13be17c8434dad"
      url="Windows%207/en_windows_7_enterprise_with_sp1_x86_dvd_u_677710.iso"
      ;;
    "winvistax64" | "winvistax64-ultimate" )
      size=3861460992
      sum="edf9f947c5791469fd7d2d40a5dcce663efa754f91847aa1d28ed7f585675b78"
      url="Windows%20Vista/en_windows_vista_sp2_x64_dvd_342267.iso"
      ;;
    "winvistax64-enterprise" )
      size=3205953536
      sum="0a0cd511b3eac95c6f081419c9c65b12317b9d6a8d9707f89d646c910e788016"
      url="Windows%20Vista/en_windows_vista_enterprise_sp2_x64_dvd_342332.iso"
      ;;
    "winvistax86" | "winvistax86-ultimate" )
      size=3243413504
      sum="9c36fed4255bd05a8506b2da88f9aad73643395e155e609398aacd2b5276289c"
      url="Windows%20Vista/en_windows_vista_with_sp2_x86_dvd_342266.iso"
      ;;
    "winvistax86-enterprise" )
      size=2420981760
      sum="54e2720004041e7db988a391543ea5228b0affc28efcf9303d2d0ff9402067f5"
      url="Windows%20Vista/en_windows_vista_enterprise_sp2_x86_dvd_342329.iso"
      ;;
    "win2003r2" )
      size=652367872
      sum="74245cba888f935b138b106c2744bec7f392925b472358960a0b5643cd6abb32"
      url="Windows%20Server%202003%20R2/en_win_srv_2003_r2_standard_x64_with_sp2_cd1_x13-05757.iso"
      ;;
    "winxpx86" )
      size=617756672
      sum="62b6c91563bad6cd12a352aa018627c314cfc5162d8e9f8af0756a642e602a46"
      url="Windows%20XP/en_windows_xp_professional_with_service_pack_3_x86_cd_x14-80428.iso"
      ;;
  esac

  case "${ret,,}" in
    "sum" ) echo "$sum" ;;
    "size" ) echo "$size" ;;
    *) [ -n "$url" ] && echo "$host/$url";;
  esac

  return 0
}

getLink3() {

  local id="$1"
  local lang="$2"
  local ret="$3"
  local url=""
  local sum=""
  local size=""
  local host="https://archive.org/download"

  [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0

  case "${id,,}" in
    "win11x64" )
      size=7736125440
      sum="d141f6030fed50f75e2b03e1eb2e53646c4b21e5386047cb860af5223f102a32"
      url="W11x64_26200.6584/26200.6584.250915-1905.25h2_ge_release_svc_refresh_CLIENT_CONSUMER_x64FRE_en-us.iso"
      ;;
    "win11x64-enterprise" )
      size=6209064960
      sum="c8dbc96b61d04c8b01faf6ce0794fdf33965c7b350eaa3eb1e6697019902945c"
      url="Windows11Enterprise23H2x64/22631.2428.231001-0608.23H2_NI_RELEASE_SVC_REFRESH_CLIENTENTERPRISEEVAL_OEMRET_x64FRE_en-us.iso"
      ;;
    "win11x64-ltsc" | "win11x64-enterprise-ltsc" )
      size=5144817664
      sum="4f59662a96fc1da48c1b415d6c369d08af55ddd64e8f1c84e0166d9e50405d7a"
      url="Windows11LTSC/X23-81951_26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_ENTERPRISES_OEM_x64FRE_en-us.iso"
      ;;
    "win11x64-iot" | "win11x64-enterprise-iot" )
      size=5144817664
      sum="4f59662a96fc1da48c1b415d6c369d08af55ddd64e8f1c84e0166d9e50405d7a"
      url="Windows11LTSC/X23-81951_26100.1742.240906-0331.ge_release_svc_refresh_CLIENT_ENTERPRISES_OEM_x64FRE_en-us.iso"
      ;;
    "win10x64" | "win10x64-enterprise" )
      size=6985445376
      sum="2c23bc8b95a9314f15ebff881dcbea49651f52a96a0327d7aaf523aa66043765"
      url="windows-10-business-editions-version-22h2-updated-oct-2025-en-us/en-us_windows_10_business_editions_version_22h2_updated_oct_2025_x64_dvd_d2eef4b0.iso"
      ;;
    "win10x64-ltsc" | "win10x64-enterprise-ltsc" )
      size=4899461120
      sum="c90a6df8997bf49e56b9673982f3e80745058723a707aef8f22998ae6479597d"
      url="en-us_windows_10_enterprise_ltsc_2021_x64_dvd_d289cf96_202302/en-us_windows_10_enterprise_ltsc_2021_x64_dvd_d289cf96.iso"
      ;;
    "win10x64-iot" | "win10x64-enterprise-iot" )
      size=4851668992
      sum="a0334f31ea7a3e6932b9ad7206608248f0bd40698bfb8fc65f14fc5e4976c160"
      url="en-us_windows_10_iot_enterprise_ltsc_2021_x64_dvd_257ad90f_202411/en-us_windows_10_iot_enterprise_ltsc_2021_x64_dvd_257ad90f.iso"
      ;;
    "win81x64" )
      size=4320526336
      sum="d8333cf427eb3318ff6ab755eb1dd9d433f0e2ae43745312c1cd23e83ca1ce51"
      url="en_windows_8.1_with_update_x64_dvd_6051480/en_windows_8.1_with_update_x64_dvd_6051480.iso"
      ;;
    "win81x64-enterprise" )
      size=4139163648
      sum="c3c604c03677504e8905090a8ce5bb1dde76b6fd58e10f32e3a25bef21b2abe1"
      url="en_windows_8.1_enterprise_with_update_x64_dvd/en_windows_8.1_enterprise_with_update_x64_dvd_6054382.iso"
      ;;
    "win2025" )
      size=8145395712
      sum="f3e277e75acdb793e6f08f4880b514ae0046cedf618c22f727890e54367075e6"
      url="en-us_windows_server_2025_updated_dec_2025_x64_dvd_c54ab58b/en-us_windows_server_2025_updated_dec_2025_x64_dvd_c54ab58b.iso"
      ;;
    "win2022" )
      size=5550684160
      sum="5a077ee2a95976ef9f3623eb4040e25cdf7f8f01dee3b8165a32a7626f39f025"
      url="en-us_windows_server_2022_x64_dvd_620d7eac_202405/en-us_windows_server_2022_x64_dvd_620d7eac.iso"
      ;;
    "win2019" )
      size=5651695616
      sum="ea247e5cf4df3e5829bfaaf45d899933a2a67b1c700a02ee8141287a8520261c"
      url="en-us_windows_server_2019_x64_dvd_f9475476_202603/en-us_windows_server_2019_x64_dvd_f9475476.iso"
      ;;
    "win2016" )
      size=6006587392
      sum="af06e5483c786c023123e325cea4775050324d9e1366f46850b515ae43f764be"
      url="en_windows_server_2016_updated_feb_2018_x64_dvd_11636692/en_windows_server_2016_updated_feb_2018_x64_dvd_11636692.iso"
      ;;
    "win2012r2" )
      size=5397889024
      sum="f351e89eb88a96af4626ceb3450248b8573e3ed5924a4e19ea891e6003b62e4e"
      url="en_windows_server_2012_r2_with_update_x64_dvd_6052708_202006/en_windows_server_2012_r2_with_update_x64_dvd_6052708.iso"
      ;;
    "win2008r2" )
      size=3166584832
      sum="dfd9890881b7e832a927c38310fb415b7ea62ac5a896671f2ce2a111998f0df8"
      url="en_windows_server_2008_r2_with_sp1_x64_dvd_617601_202006/en_windows_server_2008_r2_with_sp1_x64_dvd_617601.iso"
      ;;
    "win7x64" | "win7x64-ultimate" )
      size=3320903680
      sum="36f4fa2416d0982697ab106e3a72d2e120dbcdb6cc54fd3906d06120d0653808"
      url="win7-ult-sp1-english/Win7_Ult_SP1_English_x64.iso"
      ;;
    "win7x64-enterprise" )
      size=3182604288
      sum="ee69f3e9b86ff973f632db8e01700c5724ef78420b175d25bae6ead90f6805a7"
      url="en_windows_7_enterprise_with_sp1_x64_dvd_u_677651_202006/en_windows_7_enterprise_with_sp1_x64_dvd_u_677651.iso"
      ;;
    "win7x86" | "win7x86-ultimate" )
      size=2564476928
      sum="e2c009a66d63a742941f5087acae1aa438dcbe87010bddd53884b1af6b22c940"
      url="win7-ult-sp1-english/Win7_Ult_SP1_English_x32.iso"
      ;;
    "win7x86-enterprise" )
      size=2434502656
      sum="8bdd46ff8cb8b8de9c4aba02706629c8983c45e87da110e64e13be17c8434dad"
      url="en_windows_7_enterprise_with_sp1_x86_dvd_u_677710_202006/en_windows_7_enterprise_with_sp1_x86_dvd_u_677710.iso"
      ;;
    "winvistax64" | "winvistax64-ultimate" )
      size=3861460992
      sum="edf9f947c5791469fd7d2d40a5dcce663efa754f91847aa1d28ed7f585675b78"
      url="ms_windows_vista_sp2/en_windows_vista_sp2_x64_dvd_342267.iso"
      ;;
    "winvistax64-enterprise" )
      size=3205953536
      sum="0a0cd511b3eac95c6f081419c9c65b12317b9d6a8d9707f89d646c910e788016"
      url="en_windows_vista_enterprise_sp2_x64_dvd_342332_202007/en_windows_vista_enterprise_sp2_x64_dvd_342332.iso"
      ;;
    "winvistax86" | "winvistax86-ultimate" )
      size=3243413504
      sum="9c36fed4255bd05a8506b2da88f9aad73643395e155e609398aacd2b5276289c"
      url="en_windows_vista_sp2_x86_dvd_342266/en_windows_vista_sp2_x86_dvd_342266.iso"
      ;;
    "winvistax86-enterprise" )
      size=2420981760
      sum="54e2720004041e7db988a391543ea5228b0affc28efcf9303d2d0ff9402067f5"
      url="en_windows_vista_enterprise_sp2_x86_dvd_342329_202007/en_windows_vista_enterprise_sp2_x86_dvd_342329.iso"
      ;;
    "win2003r2" )
      size=652367872
      sum="74245cba888f935b138b106c2744bec7f392925b472358960a0b5643cd6abb32"
      url="en_win_srv_2003_r2_standard_x64_with_sp2_cd1_x13-05757/en_win_srv_2003_r2_standard_x64_with_sp2_cd1_x13-05757.iso"
      ;;
    "winxpx64" )
      size=628299776
      sum="49b87fc4a9191dcf57588a2d36a87da87a37577e0f0a57b778dc15874287f8b0"
      url="en_win_xp_pro_x64_with_sp2/CRMPXFPP_EN.iso"
      ;;
    "winxpx86" )
      size=617756672
      sum="62b6c91563bad6cd12a352aa018627c314cfc5162d8e9f8af0756a642e602a46"
      url="en_windows_xp_professional_with_service_pack_3_x86_cd_x14-80428/en_windows_xp_professional_with_service_pack_3_x86_cd_x14-80428.iso"
      ;;
    "win2kx86" )
      size=386859008
      sum="e3816f6e80b66ff686ead03eeafffe9daf020a5e4717b8bd4736b7c51733ba22"
      url="MicrosoftWindows2000BuildCollection/5.00.2195.6717_x86fre_client-professional_retail_en-us-ZRMPFPP_EN.iso"
      ;;
    "core11" )
      size=3304132608
      sum="c0e0252b24144b8defb6c7ded2bc09f9297daf1fb8369b16c5b85382331eb47f"
      url="tiny11_25H2/tiny11core_25H2_Nov25.iso"
      ;;
    "tiny11" )
      size=5730246656
      sum="7b24815845684add7250808b3b0027ba4e94cf52c62e9ef40d5b965dd304d6ca"
      url="tiny11_25H2/tiny11_25H2_Nov25.iso"
      ;;
    "tiny10" )
      size=3839819776
      sum="a11116c0645d892d6a5a7c585ecc1fa13aa66f8c7cc6b03bf1f27bd16860cc35"
      url="tiny-10-23-h2/tiny10%20x64%2023h2.iso"
      ;;
  esac

  case "${ret,,}" in
    "sum" ) echo "$sum" ;;
    "size" ) echo "$size" ;;
    *) [ -n "$url" ] && echo "$host/$url";;
  esac

  return 0
}

getValue() {

  local val=""
  local id="$2"
  local lang="$3"
  local type="$4"
  local func="getLink$1"

  if [ "$1" -gt 0 ] && [ "$1" -le "$MIRRORS" ]; then
    val=$($func "$id" "$lang" "$type")
  fi

  echo "$val"
  return 0
}

getLink() {

  getValue "$1" "$2" "$3" ""
}

getHash() {

  getValue "$1" "$2" "$3" "sum"
}

getSize() {

  getValue "$1" "$2" "$3" "size"
}

isMido() {

  local id="$1"
  local lang="$2"
  local sum

  disabled "${MIDO:-}" && return 1

  sum=$(getMido "$id" "en" "sum")
  [ -n "$sum" ] && return 0

  return 1
}

isESD() {

  local id="$1"
  local lang="$2"

  disabled "${ESD:-}" && return 1

  case "${id,,}" in
    "win11${PLATFORM,,}" | \
    "win10${PLATFORM,,}" | \
    "win11${PLATFORM,,}-enterprise" | \
    "win10${PLATFORM,,}-enterprise" )
      return 0
      ;;
  esac

  return 1
}

validVersion() {

  local id="$1"
  local lang="$2"
  local url i

  isMido "$id" "$lang" && return 0

  [[ "${id,,}" == *"-eval" ]] && id="${id::-5}"

  isESD "$id" "$lang" && return 0

  for ((i=1;i<=MIRRORS;i++)); do

    url=$(getLink "$i" "$id" "$lang")
    [ -n "$url" ] && return 0

  done

  return 1
}

isCompatible() {
  return 0
}

return 0


import mimetypes
import os
import re
import sys
import shutil
import traceback
import io
from dataclasses import dataclass
from importlib.metadata import entry_points
from typing import Any, List, Dict, Optional, Union, BinaryIO
from pathlib import Path
from urllib.parse import urlparse
from warnings import warn
import requests
import magika
import charset_normalizer
import codecs

from ._stream_info import StreamInfo
from ._uri_utils import parse_data_uri, file_uri_to_path

from .converters import (
    PlainTextConverter,
    HtmlConverter,
    RssConverter,
    WikipediaConverter,
    YouTubeConverter,
    IpynbConverter,
    BingSerpConverter,
    PdfConverter,
    DocxConverter,
    XlsxConverter,
    XlsConverter,
    PptxConverter,
    ImageConverter,
    AudioConverter,
    OutlookMsgConverter,
    ZipConverter,
    EpubConverter,
    DocumentIntelligenceConverter,
    ContentUnderstandingConverter,
    CsvConverter,
)

from ._base_converter import DocumentConverter, DocumentConverterResult

from ._exceptions import (
    FileConversionException,
    UnsupportedFormatException,
    FailedConversionAttempt,
)

# Lower priority values are tried first.
PRIORITY_SPECIFIC_FILE_FORMAT = (
    0.0  # e.g., .docx, .pdf, .xlsx, Or specific pages, e.g., wikipedia
)
PRIORITY_GENERIC_FILE_FORMAT = (
    10.0  # Near catch-all converters for mimetypes like text/*, etc.
)

_plugins: Union[None, List[Any]] = None  # If None, plugins have not been loaded yet.

def _load_plugins() -> Union[None, List[Any]]:
    """Lazy load plugins, exiting early if already loaded."""
    global _plugins

    # Skip if we've already loaded plugins
    if _plugins is not None:
        return _plugins

    # Load plugins
    _plugins = []
    for entry_point in entry_points(group="markitdown.plugin"):
        try:
            _plugins.append(entry_point.load())
        except Exception:
            tb = traceback.format_exc()
            warn(f"Plugin '{entry_point.name}' failed to load ... skipping:\n{tb}")

    return _plugins

@dataclass(kw_only=True, frozen=True)
class ConverterRegistration:
    """A registration of a converter with its priority and other metadata."""

    converter: DocumentConverter
    priority: float

class MarkItDown:
    """(In preview) An extremely simple text-based document reader, suitable for LLM use.
    This reader will convert common file-types or webpages to Markdown."""

    def __init__(
        self,
        *,
        enable_builtins: Union[None, bool] = None,
        enable_plugins: Union[None, bool] = None,
        **kwargs,
    ):
        self._builtins_enabled = False
        self._plugins_enabled = False

        requests_session = kwargs.get("requests_session")
        if requests_session is None:
            self._requests_session = requests.Session()
            # Signal that we prefer markdown over HTML, etc. if the server supports it.
            # e.g., https://blog.cloudflare.com/markdown-for-agents/
            self._requests_session.headers.update(
                {
                    "Accept": "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
                }
            )
        else:
            self._requests_session = requests_session

        self._magika = magika.Magika()

        # TODO - remove these (see enable_builtins)
        self._llm_client: Any = None
        self._llm_model: Union[str | None] = None
        self._llm_prompt: Union[str | None] = None
        self._exiftool_path: Union[str | None] = None
        self._style_map: Union[str | None] = None

        # Register the converters
        self._converters: List[ConverterRegistration] = []

        if (
            enable_builtins is None or enable_builtins
        ):  # Default to True when not specified
            self.enable_builtins(**kwargs)

        if enable_plugins:
            self.enable_plugins(**kwargs)

    def enable_builtins(self, **kwargs) -> None:
        """
        Enable and register built-in converters.
        Built-in converters are enabled by default.
        This method should only be called once, if built-ins were initially disabled.
        """
        if not self._builtins_enabled:
            # TODO: Move these into converter constructors
            self._llm_client = kwargs.get("llm_client")
            self._llm_model = kwargs.get("llm_model")
            self._llm_prompt = kwargs.get("llm_prompt")
            self._exiftool_path = kwargs.get("exiftool_path")
            self._style_map = kwargs.get("style_map")

            if self._exiftool_path is None:
                self._exiftool_path = os.getenv("EXIFTOOL_PATH")

            # Still none? Check well-known paths
            if self._exiftool_path is None:
                candidate = shutil.which("exiftool")
                if candidate:
                    candidate = os.path.abspath(candidate)
                    if any(
                        d == os.path.dirname(candidate)
                        for d in [
                            "/usr/bin",
                            "/usr/local/bin",
                            "/opt",
                            "/opt/bin",
                            "/opt/local/bin",
                            "/opt/homebrew/bin",
                            "C:\\Windows\\System32",
                            "C:\\Program Files",
                            "C:\\Program Files (x86)",
                        ]
                    ):
                        self._exiftool_path = candidate

            # Register converters for successful browsing operations
            # Later registrations are tried first / take higher priority than earlier registrations
            # To this end, the most specific converters should appear below the most generic converters
            self.register_converter(
                PlainTextConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
            )
            self.register_converter(
                ZipConverter(markitdown=self), priority=PRIORITY_GENERIC_FILE_FORMAT
            )
            self.register_converter(
                HtmlConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
            )
            self.register_converter(RssConverter())
            self.register_converter(WikipediaConverter())
            self.register_converter(YouTubeConverter())
            self.register_converter(BingSerpConverter())
            self.register_converter(DocxConverter())
            self.register_converter(XlsxConverter())
            self.register_converter(XlsConverter())
            self.register_converter(PptxConverter())
            self.register_converter(AudioConverter())
            self.register_converter(ImageConverter())
            self.register_converter(IpynbConverter())
            self.register_converter(PdfConverter())
            self.register_converter(OutlookMsgConverter())
            self.register_converter(EpubConverter())
            self.register_converter(CsvConverter())

            # Register Document Intelligence converter at the top of the stack if endpoint is provided
            docintel_endpoint = kwargs.get("docintel_endpoint")
            if docintel_endpoint is not None:
                docintel_args: Dict[str, Any] = {}
                docintel_args["endpoint"] = docintel_endpoint

                docintel_credential = kwargs.get("docintel_credential")
                if docintel_credential is not None:
                    docintel_args["credential"] = docintel_credential

                docintel_types = kwargs.get("docintel_file_types")
                if docintel_types is not None:
                    docintel_args["file_types"] = docintel_types

                docintel_version = kwargs.get("docintel_api_version")
                if docintel_version is not None:
                    docintel_args["api_version"] = docintel_version

                self.register_converter(
                    DocumentIntelligenceConverter(**docintel_args),
                )

            # Register Content Understanding converter at the top of the stack if endpoint is provided
            cu_endpoint = kwargs.get("cu_endpoint")
            if cu_endpoint is not None:
                cu_args: Dict[str, Any] = {}
                cu_args["endpoint"] = cu_endpoint

                cu_credential = kwargs.get("cu_credential")
                if cu_credential is not None:
                    cu_args["credential"] = cu_credential

                cu_analyzer_id = kwargs.get("cu_analyzer_id")
                if cu_analyzer_id is not None:
                    cu_args["analyzer_id"] = cu_analyzer_id

                cu_file_types = kwargs.get("cu_file_types")
                if cu_file_types is not None:
                    cu_args["file_types"] = cu_file_types

                self.register_converter(
                    ContentUnderstandingConverter(**cu_args),
                )

            self._builtins_enabled = True
        else:
            warn("Built-in converters are already enabled.", RuntimeWarning)

    def enable_plugins(self, **kwargs) -> None:
        """
        Enable and register converters provided by plugins.
        Plugins are disabled by default.
        This method should only be called once, if plugins were initially disabled.
        """
        if not self._plugins_enabled:
            # Load plugins
            plugins = _load_plugins()
            assert plugins is not None
            for plugin in plugins:
                try:
                    plugin.register_converters(self, **kwargs)
                except Exception:
                    tb = traceback.format_exc()
                    warn(f"Plugin '{plugin}' failed to register converters:\n{tb}")
            self._plugins_enabled = True
        else:
            warn("Plugin converters are already enabled.", RuntimeWarning)

    def convert(
        self,
        source: Union[str, requests.Response, Path, BinaryIO],
        *,
        stream_info: Optional[StreamInfo] = None,
        **kwargs: Any,
    ) -> DocumentConverterResult:  # TODO: deal with kwargs
        """
        Args:
            - source: can be a path (str or Path), url, or a requests.response object
            - stream_info: optional stream info to use for the conversion. If None, infer from source
            - kwargs: additional arguments to pass to the converter
        """

        # Local path or url
        if isinstance(source, str):
            if (
                source.startswith("http:")
                or source.startswith("https:")
                or source.startswith("file:")
                or source.startswith("data:")
            ):
                # Rename the url argument to mock_url
                # (Deprecated -- use stream_info)
                _kwargs = {k: v for k, v in kwargs.items()}
                if "url" in _kwargs:
                    _kwargs["mock_url"] = _kwargs["url"]
                    del _kwargs["url"]

                return self.convert_uri(source, stream_info=stream_info, **_kwargs)
            else:
                return self.convert_local(source, stream_info=stream_info, **kwargs)
        # Path object
        elif isinstance(source, Path):
            return self.convert_local(source, stream_info=stream_info, **kwargs)
        # Request response
        elif isinstance(source, requests.Response):
            return self.convert_response(source, stream_info=stream_info, **kwargs)
        # Binary stream
        elif (
            hasattr(source, "read")
            and callable(source.read)
            and not isinstance(source, io.TextIOBase)
        ):
            return self.convert_stream(source, stream_info=stream_info, **kwargs)
        else:
            raise TypeError(
                f"Invalid source type: {type(source)}. Expected str, requests.Response, BinaryIO."
            )

    def convert_local(
        self,
        path: Union[str, Path],
        *,
        stream_info: Optional[StreamInfo] = None,
        file_extension: Optional[str] = None,  # Deprecated -- use stream_info
        url: Optional[str] = None,  # Deprecated -- use stream_info
        **kwargs: Any,
    ) -> DocumentConverterResult:
        if isinstance(path, Path):
            path = str(path)

        # Build a base StreamInfo object from which to start guesses
        base_guess = StreamInfo(
            local_path=path,
            extension=os.path.splitext(path)[1],
            filename=os.path.basename(path),
        )

        # Extend the base_guess with any additional info from the arguments
        if stream_info is not None:
            base_guess = base_guess.copy_and_update(stream_info)

        if file_extension is not None:
            # Deprecated -- use stream_info
            base_guess = base_guess.copy_and_update(extension=file_extension)

        if url is not None:
            # Deprecated -- use stream_info
            base_guess = base_guess.copy_and_update(url=url)

        with open(path, "rb") as fh:
            guesses = self._get_stream_info_guesses(
                file_stream=fh, base_guess=base_guess
            )
            return self._convert(file_stream=fh, stream_info_guesses=guesses, **kwargs)

    def convert_stream(
        self,
        stream: BinaryIO,
        *,
        stream_info: Optional[StreamInfo] = None,
        file_extension: Optional[str] = None,  # Deprecated -- use stream_info
        url: Optional[str] = None,  # Deprecated -- use stream_info
        **kwargs: Any,
    ) -> DocumentConverterResult:
        guesses: List[StreamInfo] = []

        # Do we have anything on which to base a guess?
        base_guess = None
        if stream_info is not None or file_extension is not None or url is not None:
            # Start with a non-Null base guess
            if stream_info is None:
                base_guess = StreamInfo()
            else:
                base_guess = stream_info

            if file_extension is not None:
                # Deprecated -- use stream_info
                assert base_guess is not None  # for mypy
                base_guess = base_guess.copy_and_update(extension=file_extension)

            if url is not None:
                # Deprecated -- use stream_info
                assert base_guess is not None  # for mypy
                base_guess = base_guess.copy_and_update(url=url)

        # Check if we have a seekable stream. If not, load the entire stream into memory.
        if not stream.seekable():
            buffer = io.BytesIO()
            while True:
                chunk = stream.read(4096)
                if not chunk:
                    break
                buffer.write(chunk)
            buffer.seek(0)
            stream = buffer

        # Add guesses based on stream content
        guesses = self._get_stream_info_guesses(
            file_stream=stream, base_guess=base_guess or StreamInfo()
        )
        return self._convert(file_stream=stream, stream_info_guesses=guesses, **kwargs)

    def convert_url(
        self,
        url: str,
        *,
        stream_info: Optional[StreamInfo] = None,
        file_extension: Optional[str] = None,
        mock_url: Optional[str] = None,
        **kwargs: Any,
    ) -> DocumentConverterResult:
        """Alias for convert_uri()"""
        # convert_url will likely be deprecated in the future in favor of convert_uri
        return self.convert_uri(
            url,
            stream_info=stream_info,
            file_extension=file_extension,
            mock_url=mock_url,
            **kwargs,
        )

    def convert_uri(
        self,
        uri: str,
        *,
        stream_info: Optional[StreamInfo] = None,
        file_extension: Optional[str] = None,  # Deprecated -- use stream_info
        mock_url: Optional[
            str
        ] = None,  # Mock the request as if it came from a different URL
        **kwargs: Any,
    ) -> DocumentConverterResult:
        uri = uri.strip()

        # File URIs
        if uri.startswith("file:"):
            netloc, path = file_uri_to_path(uri)
            if netloc and netloc != "localhost":
                raise ValueError(
                    f"Unsupported file URI: {uri}. Netloc must be empty or localhost."
                )
            return self.convert_local(
                path,
                stream_info=stream_info,
                file_extension=file_extension,
                url=mock_url,
                **kwargs,
            )
        # Data URIs
        elif uri.startswith("data:"):
            mimetype, attributes, data = parse_data_uri(uri)

            base_guess = StreamInfo(
                mimetype=mimetype,
                charset=attributes.get("charset"),
            )
            if stream_info is not None:
                base_guess = base_guess.copy_and_update(stream_info)

            return self.convert_stream(
                io.BytesIO(data),
                stream_info=base_guess,
                file_extension=file_extension,
                url=mock_url,
                **kwargs,
            )
        # HTTP/HTTPS URIs
        elif uri.startswith("http:") or uri.startswith("https:"):
            response = self._requests_session.get(uri, stream=True)
            response.raise_for_status()
            return self.convert_response(
                response,
                stream_info=stream_info,
                file_extension=file_extension,
                url=mock_url,
                **kwargs,
            )
        else:
            raise ValueError(
                f"Unsupported URI scheme: {uri.split(':')[0]}. Supported schemes are: file:, data:, http:, https:"
            )

    def convert_response(
        self,
        response: requests.Response,
        *,
        stream_info: Optional[StreamInfo] = None,
        file_extension: Optional[str] = None,  # Deprecated -- use stream_info
        url: Optional[str] = None,  # Deprecated -- use stream_info
        **kwargs: Any,
    ) -> DocumentConverterResult:
        # If there is a content-type header, get the mimetype and charset (if present)
        mimetype: Optional[str] = None
        charset: Optional[str] = None

        if "content-type" in response.headers:
            parts = response.headers["content-type"].split(";")
            mimetype = parts.pop(0).strip()
            for part in parts:
                if part.strip().startswith("charset="):
                    _charset = part.split("=")[1].strip()
                    if len(_charset) > 0:
                        charset = _charset

        # If there is a content-disposition header, get the filename and possibly the extension
        filename: Optional[str] = None
        extension: Optional[str] = None
        if "content-disposition" in response.headers:
            m = re.search(r"filename=([^;]+)", response.headers["content-disposition"])
            if m:
                filename = m.group(1).strip("\"'")
                _, _extension = os.path.splitext(filename)
                if len(_extension) > 0:
                    extension = _extension

        # If there is still no filename, try to read it from the url
        if filename is None:
            parsed_url = urlparse(response.url)
            _, _extension = os.path.splitext(parsed_url.path)
            if len(_extension) > 0:  # Looks like this might be a file!
                filename = os.path.basename(parsed_url.path)
                extension = _extension

        # Create an initial guess from all this information
        base_guess = StreamInfo(
            mimetype=mimetype,
            charset=charset,
            filename=filename,
            extension=extension,
            url=response.url,
        )

        # Update with any additional info from the arguments
        if stream_info is not None:
            base_guess = base_guess.copy_and_update(stream_info)
        if file_extension is not None:
            # Deprecated -- use stream_info
            base_guess = base_guess.copy_and_update(extension=file_extension)
        if url is not None:
            # Deprecated -- use stream_info
            base_guess = base_guess.copy_and_update(url=url)

        # Read into BytesIO
        buffer = io.BytesIO()
        for chunk in response.iter_content(chunk_size=512):
            buffer.write(chunk)
        buffer.seek(0)

        # Convert
        guesses = self._get_stream_info_guesses(
            file_stream=buffer, base_guess=base_guess
        )
        return self._convert(file_stream=buffer, stream_info_guesses=guesses, **kwargs)

    def _convert(
        self, *, file_stream: BinaryIO, stream_info_guesses: List[StreamInfo], **kwargs
    ) -> DocumentConverterResult:
        res: Union[None, DocumentConverterResult] = None

        # Keep track of which converters throw exceptions
        failed_attempts: List[FailedConversionAttempt] = []

        # Create a copy of the page_converters list, sorted by priority.
        # We do this with each call to _convert because the priority of converters may change between calls.
        # The sort is guaranteed to be stable, so converters with the same priority will remain in the same order.
        sorted_registrations = sorted(self._converters, key=lambda x: x.priority)

        # Remember the initial stream position so that we can return to it
        cur_pos = file_stream.tell()

        for stream_info in stream_info_guesses + [StreamInfo()]:
            for converter_registration in sorted_registrations:
                converter = converter_registration.converter
                # Sanity check -- make sure the cur_pos is still the same
                assert (
                    cur_pos == file_stream.tell()
                ), "File stream position should NOT change between guess iterations"

                _kwargs = {k: v for k, v in kwargs.items()}

                # Copy any additional global options
                if "llm_client" not in _kwargs and self._llm_client is not None:
                    _kwargs["llm_client"] = self._llm_client

                if "llm_model" not in _kwargs and self._llm_model is not None:
                    _kwargs["llm_model"] = self._llm_model

                if "llm_prompt" not in _kwargs and self._llm_prompt is not None:
                    _kwargs["llm_prompt"] = self._llm_prompt

                if "style_map" not in _kwargs and self._style_map is not None:
                    _kwargs["style_map"] = self._style_map

                if "exiftool_path" not in _kwargs and self._exiftool_path is not None:
                    _kwargs["exiftool_path"] = self._exiftool_path

                # Add the list of converters for nested processing
                _kwargs["_parent_converters"] = self._converters

                # Add legacy kwargs
                if stream_info is not None:
                    if stream_info.extension is not None:
                        _kwargs["file_extension"] = stream_info.extension

                    if stream_info.url is not None:
                        _kwargs["url"] = stream_info.url

                # Check if the converter will accept the file, and if so, try to convert it
                _accepts = False
                try:
                    _accepts = converter.accepts(file_stream, stream_info, **_kwargs)
                except NotImplementedError:
                    pass

                # accept() should not have changed the file stream position
                assert (
                    cur_pos == file_stream.tell()
                ), f"{type(converter).__name__}.accept() should NOT change the file_stream position"

                # Attempt the conversion
                if _accepts:
                    try:
                        res = converter.convert(file_stream, stream_info, **_kwargs)
                    except Exception:
                        failed_attempts.append(
                            FailedConversionAttempt(
                                converter=converter, exc_info=sys.exc_info()
                            )
                        )
                    finally:
                        file_stream.seek(cur_pos)

                if res is not None:
                    # Normalize the content
                    res.text_content = "\n".join(
                        [line.rstrip() for line in re.split(r"\r?\n", res.text_content)]
                    )
                    res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
                    return res

        # If we got this far without success, report any exceptions
        if len(failed_attempts) > 0:
            raise FileConversionException(attempts=failed_attempts)

        # Nothing can handle it!
        raise UnsupportedFormatException(
            "Could not convert stream to Markdown. No converter attempted a conversion, suggesting that the filetype is simply not supported."
        )

    def register_page_converter(self, converter: DocumentConverter) -> None:
        """DEPRECATED: Use register_converter instead."""
        warn(
            "register_page_converter is deprecated. Use register_converter instead.",
            DeprecationWarning,
        )
        self.register_converter(converter)

    def register_converter(
        self,
        converter: DocumentConverter,
        *,
        priority: float = PRIORITY_SPECIFIC_FILE_FORMAT,
    ) -> None:
        """
        Register a DocumentConverter with a given priority.

        Priorities work as follows: By default, most converters get priority
        PRIORITY_SPECIFIC_FILE_FORMAT (== 0). The exception
        is the PlainTextConverter, HtmlConverter, and ZipConverter, which get
        priority PRIORITY_GENERIC_FILE_FORMAT (== 10), with lower values
        being tried first (i.e., higher priority).

        Just prior to conversion, the converters are sorted by priority, using
        a stable sort. This means that converters with the same priority will
        remain in the same order, with the most recently registered converters
        appearing first.

        We have tight control over the order of built-in converters, but
        plugins can register converters in any order. The registration's priority
        field reasserts some control over the order of converters.

        Plugins can register converters with any priority, to appear before or
        after the built-ins. For example, a plugin with priority 9 will run
        before the PlainTextConverter, but after the built-in converters.
        """
        self._converters.insert(
            0, ConverterRegistration(converter=converter, priority=priority)
        )

    def _get_stream_info_guesses(
        self, file_stream: BinaryIO, base_guess: StreamInfo
    ) -> List[StreamInfo]:
        """
        Given a base guess, attempt to guess or expand on the stream info using the stream content (via magika).
        """
        guesses: List[StreamInfo] = []

        # Enhance the base guess with information based on the extension or mimetype
        enhanced_guess = base_guess.copy_and_update()

        # If there's an extension and no mimetype, try to guess the mimetype
        if base_guess.mimetype is None and base_guess.extension is not None:
            _m, _ = mimetypes.guess_type(
                "placeholder" + base_guess.extension, strict=False
            )
            if _m is not None:
                enhanced_guess = enhanced_guess.copy_and_update(mimetype=_m)

        # If there's a mimetype and no extension, try to guess the extension
        if base_guess.mimetype is not None and base_guess.extension is None:
            _e = mimetypes.guess_all_extensions(base_guess.mimetype, strict=False)
            if len(_e) > 0:
                enhanced_guess = enhanced_guess.copy_and_update(extension=_e[0])

        # Call magika to guess from the stream
        cur_pos = file_stream.tell()
        try:
            result = self._magika.identify_stream(file_stream)
            if result.status == "ok" and result.prediction.output.label != "unknown":
                # If it's text, also guess the charset
                charset = None
                if result.prediction.output.is_text:
                    # Read the first 4k to guess the charset
                    file_stream.seek(cur_pos)
                    stream_page = file_stream.read(4096)
                    charset_result = charset_normalizer.from_bytes(stream_page).best()

                    if charset_result is not None:
                        charset = self._normalize_charset(charset_result.encoding)

                # Normalize the first extension listed
                guessed_extension = None
                if len(result.prediction.output.extensions) > 0:
                    guessed_extension = "." + result.prediction.output.extensions[0]

                # Determine if the guess is compatible with the base guess
                compatible = True
                if (
                    base_guess.mimetype is not None
                    and base_guess.mimetype != result.prediction.output.mime_type
                ):
                    compatible = False

                if (
                    base_guess.extension is not None
                    and base_guess.extension.lstrip(".")
                    not in result.prediction.output.extensions
                ):
                    compatible = False

                if (
                    base_guess.charset is not None
                    and self._normalize_charset(base_guess.charset) != charset
                ):
                    compatible = False

                if compatible:
                    # Add the compatible base guess
                    guesses.append(
                        StreamInfo(
                            mimetype=base_guess.mimetype
                            or result.prediction.output.mime_type,
                            extension=base_guess.extension or guessed_extension,
                            charset=base_guess.charset or charset,
                            filename=base_guess.filename,
                            local_path=base_guess.local_path,
                            url=base_guess.url,
                        )
                    )
                else:
                    # The magika guess was incompatible with the base guess, so add both guesses
                    guesses.append(enhanced_guess)
                    guesses.append(
                        StreamInfo(
                            mimetype=result.prediction.output.mime_type,
                            extension=guessed_extension,
                            charset=charset,
                            filename=base_guess.filename,
                            local_path=base_guess.local_path,
                            url=base_guess.url,
                        )
                    )
            else:
                # There were no other guesses, so just add the base guess
                guesses.append(enhanced_guess)
        finally:
            file_stream.seek(cur_pos)

        return guesses

    def _normalize_charset(self, charset: str | None) -> str | None:
        """
        Normalize a charset string to a canonical form.
        """
        if charset is None:
            return None
        try:
            return codecs.lookup(charset).name
        except LookupError:
            return charset


/*
 * Copyright 2024 CloudWeGo Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package parser

import (
	"context"
	"errors"
	"io"
	"path/filepath"

	"github.com/cloudwego/eino/schema"
)

// ExtParserConfig defines the configuration for the ExtParser.
type ExtParserConfig struct {
	// ext -> parser.
	// eg: map[string]Parser{
	// 	".pdf": &PDFParser{},
	// 	".md": &MarkdownParser{},
	// }
	Parsers map[string]Parser

	// Fallback parser to use when no other parser is found.
	// Default is TextParser if not set.
	FallbackParser Parser
}

// ExtParser is a parser that uses the file extension to determine which parser to use.
// You can register your own parsers by calling RegisterParser.
// Default parser is TextParser.
// Note:
//
//	parse 时，是通过 filepath.Ext(uri) 的方式找到对应的 parser，因此使用时需要：
//	 	① 必须使用 parser.WithURI 在请求时传入 URI
//	 	② URI 必须能通过 filepath.Ext 来解析出符合预期的 ext
//
// eg:
//
//	pdf, _ := os.Open("./testdata/test.pdf")
//	docs, err := ExtParser.Parse(ctx, pdf, parser.WithURI("./testdata/test.pdf"))
type ExtParser struct {
	parsers map[string]Parser

	fallbackParser Parser
}

// NewExtParser creates a new ExtParser.
func NewExtParser(ctx context.Context, conf *ExtParserConfig) (*ExtParser, error) {
	if conf == nil {
		conf = &ExtParserConfig{}
	}

	p := &ExtParser{
		parsers:        conf.Parsers,
		fallbackParser: conf.FallbackParser,
	}

	if p.fallbackParser == nil {
		p.fallbackParser = TextParser{}
	}

	if p.parsers == nil {
		p.parsers = make(map[string]Parser)
	}

	return p, nil
}

// GetParsers returns a copy of the registered parsers.
// It is safe to modify the returned parsers.
func (p *ExtParser) GetParsers() map[string]Parser {
	res := make(map[string]Parser, len(p.parsers))
	for k, v := range p.parsers {
		res[k] = v
	}

	return res
}

// Parse parses the given reader and returns a list of documents.
func (p *ExtParser) Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error) {
	opt := GetCommonOptions(&Options{}, opts...)

	ext := filepath.Ext(opt.URI)

	parser, ok := p.parsers[ext]

	if !ok {
		parser = p.fallbackParser
	}

	if parser == nil {
		return nil, errors.New("no parser found for extension " + ext)
	}

	docs, err := parser.Parse(ctx, reader, opts...)
	if err != nil {
		return nil, err
	}

	for _, doc := range docs {
		if doc == nil {
			continue
		}

		if doc.MetaData == nil {
			doc.MetaData = make(map[string]any)
		}

		for k, v := range opt.ExtraMeta {
			doc.MetaData[k] = v
		}
	}

	return docs, nil
}


// openalex work — fetch a single Work's record from OpenAlex.
//
// Hits `https://api.openalex.org/works/<id-or-doi>`. Accepts an OpenAlex
// Work id (`W2741809807`), a raw DOI (`10.7717/peerj.4375`), or a full
// `doi.org` / `openalex.org` URL. Returns one row plus the (decoded)
// abstract — OpenAlex stores abstracts as `abstract_inverted_index` so we
// reconstruct it for downstream readers.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
    OPENALEX_BASE,
    appendMailto,
    bareDoi,
    bareId,
    openalexFetch,
    reconstructAbstract,
    requireWorkRef,
} from './utils.js';

const SELECT_FIELDS = [
    'id', 'doi', 'title', 'publication_year', 'publication_date',
    'cited_by_count', 'authorships', 'primary_location', 'open_access', 'type',
    'referenced_works', 'related_works', 'language', 'abstract_inverted_index',
].join(',');

cli({
    site: 'openalex',
    name: 'work',
    access: 'read',
    description: 'Fetch a single OpenAlex Work (paper / preprint / book) — metadata + abstract',
    domain: 'api.openalex.org',
    strategy: Strategy.PUBLIC,
    browser: false,
    args: [
        { name: 'id', positional: true, required: true, help: 'OpenAlex Work id ("W2741809807"), DOI ("10.7717/peerj.4375"), or full URL' },
    ],
    columns: ['id', 'title', 'type', 'year', 'date', 'language', 'authors', 'venue', 'citations', 'openAccess', 'openAccessUrl', 'referencedCount', 'doi', 'abstract', 'url'],
    func: async (args) => {
        const ref = requireWorkRef(args.id);
        const url = appendMailto(`${OPENALEX_BASE}/works/${encodeURIComponent(ref)}?select=${SELECT_FIELDS}`);
        const w = await openalexFetch(url, 'openalex work');
        const authors = Array.isArray(w.authorships)
            ? w.authorships.map((a) => String(a?.author?.display_name ?? '').trim()).filter(Boolean).join(', ')
            : '';
        const venue = String(w.primary_location?.source?.display_name ?? '').trim();
        const id = bareId(w.id);
        const oaUrl = String(w.open_access?.oa_url ?? '').trim();
        return [{
            id,
            title: String(w.title ?? '').trim(),
            type: String(w.type ?? '').trim(),
            year: w.publication_year != null ? Number(w.publication_year) : null,
            date: String(w.publication_date ?? '').trim(),
            language: String(w.language ?? '').trim(),
            authors,
            venue,
            citations: w.cited_by_count != null ? Number(w.cited_by_count) : null,
            openAccess: Boolean(w.open_access?.is_oa),
            openAccessUrl: oaUrl,
            referencedCount: Array.isArray(w.referenced_works) ? w.referenced_works.length : null,
            doi: bareDoi(w.doi),
            abstract: reconstructAbstract(w.abstract_inverted_index),
            url: id ? `https://openalex.org/${id}` : '',
        }];
    },
});


import logging
from itertools import chain
from re import compile as re_compile
from contextvars import ContextVar, Token

from lxml import html

from scrapling.core._types import Any, Dict, Iterable, List

# Using cache on top of a class is a brilliant way to achieve a Singleton design pattern without much code
from functools import lru_cache  # isort:skip

html_forbidden = (html.HtmlComment,)

__CLEANING_TABLE__ = str.maketrans({"\t": " ", "\n": None, "\r": None})
__CONSECUTIVE_SPACES_REGEX__ = re_compile(r" +")

@lru_cache(1, typed=True)
def setup_logger():
    """Create and configure a logger with a standard format.

    :returns: logging.Logger: Configured logger instance
    """
    logger = logging.getLogger("scrapling")
    logger.setLevel(logging.INFO)

    formatter = logging.Formatter(fmt="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")

    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)

    # Add handler to logger (if not already added)
    if not logger.handlers:
        logger.addHandler(console_handler)

    return logger

_current_logger: ContextVar[logging.Logger] = ContextVar("scrapling_logger", default=setup_logger())

class LoggerProxy:
    def __getattr__(self, name: str):
        return getattr(_current_logger.get(), name)

log = LoggerProxy()

def set_logger(logger: logging.Logger) -> Token:
    """Set the current context logger. Returns token for reset."""
    return _current_logger.set(logger)

def reset_logger(token: Token) -> None:
    """Reset logger to previous state using token."""
    _current_logger.reset(token)

def flatten(lst: Iterable[Any]) -> List[Any]:
    return list(chain.from_iterable(lst))

def _is_iterable(obj: Any) -> bool:
    # This will be used only in regex functions to make sure it's iterable but not string/bytes
    return isinstance(
        obj,
        (
            list,
            tuple,
        ),
    )

class _StorageTools:
    @staticmethod
    def __clean_attributes(element: html.HtmlElement, forbidden: tuple = ()) -> Dict:
        if not element.attrib:
            return {}
        return {k: v.strip() for k, v in element.attrib.items() if v and v.strip() and k not in forbidden}

    @classmethod
    def element_to_dict(cls, element: html.HtmlElement) -> Dict:
        parent = element.getparent()
        result = {
            "tag": str(element.tag),
            "attributes": cls.__clean_attributes(element),
            "text": element.text.strip() if element.text else None,
            "path": cls._get_element_path(element),
        }
        if parent is not None:
            result.update(
                {
                    "parent_name": parent.tag,
                    "parent_attribs": dict(parent.attrib),
                    "parent_text": parent.text.strip() if parent.text else None,
                }
            )

            siblings = [child.tag for child in parent.iterchildren() if child != element]
            if siblings:
                result.update({"siblings": tuple(siblings)})

        children = [child.tag for child in element.iterchildren() if not isinstance(child, html_forbidden)]
        if children:
            result.update({"children": tuple(children)})

        return result

    @classmethod
    def _get_element_path(cls, element: html.HtmlElement):
        parent = element.getparent()
        return tuple((element.tag,) if parent is None else (cls._get_element_path(parent) + (element.tag,)))

@lru_cache(128, typed=True)
def clean_spaces(string):
    string = string.translate(__CLEANING_TABLE__)
    return __CONSECUTIVE_SPACES_REGEX__.sub(" ", string)