#!/usr/bin/env python3
"""
novelWriter – Packaging Utils
=============================

File History:
Created: 2019-05-16 [0.5.1]
Renamed: 2023-07-26 [2.1b1]

This file is a part of novelWriter
Copyright 2018–2023, Veronica Berglyd Olsen

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import os
import sys
import shutil
import zipfile
import datetime
import subprocess
import email.utils

OS_NONE   = 0
OS_LINUX  = 1
OS_WIN    = 2
OS_DARWIN = 3


# =============================================================================================== #
#  Utilities
# =============================================================================================== #

def extractVersion(beQuiet=False):
    """Extract the novelWriter version number without having to import
    anything else from the main package.
    """
    def getValue(theString):
        theBits = theString.partition("=")
        return theBits[2].strip().strip('"')

    numVers = "0"
    hexVers = "0x0"
    relDate = "Unknown"
    initFile = os.path.join("novelwriter", "__init__.py")
    try:
        with open(initFile, mode="r", encoding="utf-8") as inFile:
            for aLine in inFile:
                if aLine.startswith("__version__"):
                    numVers = getValue((aLine))
                if aLine.startswith("__hexversion__"):
                    hexVers = getValue((aLine))
                if aLine.startswith("__date__"):
                    relDate = getValue((aLine))
    except Exception as exc:
        print("Could not read file: %s" % initFile)
        print(str(exc))

    if not beQuiet:
        print("novelWriter version: %s (%s) at %s" % (numVers, hexVers, relDate))

    return numVers, hexVers, relDate


def compactVersion(version):
    """Make the version number more compact."""
    return version.replace("-alpha", "a").replace("-beta", "b").replace("-rc", "rc")


def sysCall(callArgs, cwd=None):
    """Wrapper function for system calls."""
    sysP = subprocess.Popen(
        callArgs, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        shell=True, cwd=cwd
    )
    stdOut, stdErr = sysP.communicate()
    return stdOut.decode("utf-8"), stdErr.decode("utf-8"), sysP.returncode


def readFile(fileName):
    """Read an entire file and return as a string."""
    with open(fileName, mode="r", encoding="utf-8") as inFile:
        return inFile.read()


def writeFile(fileName, writeText):
    """Write string to file."""
    with open(fileName, mode="w+", encoding="utf-8") as outFile:
        outFile.write(writeText)


def toUpload(srcPath, dstName=None):
    """Copy a file produced by one of the build functions to the upload
    directory. The file can optionally be given a new name.
    """
    uplDir = "dist_upload"
    if not os.path.isdir(uplDir):
        os.mkdir(uplDir)
    if dstName is None:
        dstName = os.path.basename(srcPath)
    shutil.copyfile(srcPath, os.path.join(uplDir, dstName))
    return


def makeCheckSum(sumFile, cwd=None):
    """Create a SHA256 checksum file."""
    try:
        if cwd is None:
            shaFile = sumFile+".sha256"
        else:
            shaFile = os.path.join(cwd, sumFile+".sha256")
        with open(shaFile, mode="w") as fOut:
            subprocess.call(["shasum", "-a", "256", sumFile], stdout=fOut, cwd=cwd)
        print("SHA256 Sum: %s" % shaFile)
    except Exception as exc:
        print("Could not generate sha256 file")
        print(str(exc))
        return ""

    return shaFile


# =============================================================================================== #
#  General
# =============================================================================================== #

##
# Package Installer (pip)
##

def installPackages(hostOS):
    """Install package dependencies both for this script and for running
    novelWriter itself.
    """
    print("")
    print("Installing Dependencies")
    print("=======================")
    print("")

    installQueue = ["pip", "-r requirements.txt"]
    if hostOS == OS_DARWIN:
        installQueue.append("pyobjc")
    elif hostOS == OS_WIN:
        installQueue.append("pywin32")

    pyCmd = [sys.executable, "-m"]
    pipCmd = ["pip", "install", "--user", "--upgrade"]
    for stepCmd in installQueue:
        pkgCmd = stepCmd.split(" ")
        try:
            subprocess.call(pyCmd + pipCmd + pkgCmd)
        except Exception as exc:
            print("Failed with error:")
            print(str(exc))
            sys.exit(1)

    return


##
#  Clean Build and Dist Folders (build-clean)
##

def cleanBuildDirs():
    """Recursively delete the 'build' and 'dist' folders."""
    print("")
    print("Cleaning up build environment ...")
    print("")

    def removeFolder(rmDir):
        if os.path.isdir(rmDir):
            try:
                shutil.rmtree(rmDir)
                print("Deleted: %s" % rmDir)
            except OSError:
                print("Failed:  %s" % rmDir)
        else:
            print("Missing: %s" % rmDir)

    removeFolder("build")
    removeFolder("dist")
    removeFolder("dist_deb")
    removeFolder("dist_minimal")
    removeFolder("dist_appimage")
    removeFolder("novelWriter.egg-info")

    print("")

    return


# =============================================================================================== #
#  Additional Builds
# =============================================================================================== #

##
#  Build PDF Manual (manual)
##

def buildPdfManual():
    """This function will build the documentation as manual.pdf."""
    print("")
    print("Building PDF Manual")
    print("===================")
    print("")

    buildFile = os.path.join("docs", "build", "latex", "manual.pdf")
    finalFile = os.path.join("novelwriter", "assets", "manual.pdf")

    if os.path.isfile(finalFile):
        # Make sure a new file is always generated
        os.unlink(finalFile)

    try:
        subprocess.call(["make", "clean"], cwd="docs")
        exCode = subprocess.call(["make", "latexpdf"], cwd="docs")
        if exCode == 0:
            if os.path.isfile(finalFile):
                os.unlink(finalFile)
            print("")
            os.rename(buildFile, finalFile)
        else:
            raise Exception(f"Build returned error code {exCode}")

        print("PDF manual build: OK")
        print("")

    except Exception as exc:
        print("PDF manual build: FAILED")
        print("")
        print(str(exc))
        print("")
        print("Dependencies:")
        print(" * pip install sphinx")
        print(" * Package latexmk")
        print(" * LaTeX build system")
        print("")
        print(" On Debian/Ubuntu, install: python3-sphinx latexmk texlive texlive-latex-extra")
        print("")
        sys.exit(1)

    if not os.path.isfile(finalFile):
        print("No output file was found!")
        print("")
        sys.exit(1)

    return


##
#  Qt Linguist QM Builder (qtlrelease)
##

def buildQtI18n():
    """Build the lang.qm files for Qt Linguist."""
    print("")
    print("Building Qt Localisation Files")
    print("==============================")

    print("")
    print("TS Files to Build:")
    print("")

    tsList = []
    for aFile in os.listdir("i18n"):
        aPath = os.path.join("i18n", aFile)
        if os.path.isfile(aPath) and aFile.endswith(".ts") and aFile != "nw_base.ts":
            tsList.append(aPath)
            print(aPath)

    print("")
    print("Building Translation Files:")
    print("")

    try:
        subprocess.call(["lrelease", "-verbose", *tsList])
    except Exception as exc:
        print("Qt5 Linguist tools seem to be missing")
        print("On Debian/Ubuntu, install: qttools5-dev-tools pyqt5-dev-tools")
        print(str(exc))
        sys.exit(1)

    print("")
    print("Moving QM Files to Assets")
    print("")

    langDir = os.path.join("novelwriter", "assets", "i18n")
    for langFile in os.listdir("i18n"):
        langPath = os.path.join("i18n", langFile)
        if not os.path.isfile(langPath):
            continue

        if langFile.endswith(".qm"):
            destPath = os.path.join(langDir, langFile)
            os.rename(langPath, destPath)
            print("Moved: %s -> %s" % (langPath, destPath))

    print("")

    return


##
#  Qt Linguist TS Builder (qtlupdate)
##

def buildQtI18nTS(sysArgs):
    """Build the lang.ts files for Qt Linguist."""
    print("")
    print("Building Qt Translation Files")
    print("=============================")

    try:
        from PyQt6.lupdate import lupdate
    except ImportError:
        print("ERROR: This command requires lupdate from PyQt6")
        sys.exit(1)

    print("")
    print("Scanning Source Tree:")
    print("")

    srcList = [os.path.join("i18n", "qtbase.py")]
    for nRoot, _, nFiles in os.walk("novelwriter"):
        if os.path.isdir(nRoot):
            for aFile in nFiles:
                aPath = os.path.join(nRoot, aFile)
                if os.path.isfile(aPath) and aFile.endswith(".py"):
                    srcList.append(aPath)

    for aSource in srcList:
        print(aSource)

    print("")
    print("TS Files to Update:")
    print("")

    tsList = []
    if len(sysArgs) >= 2:
        for anArg in sysArgs[1:]:
            if not (anArg.startswith("i18n") and anArg.endswith(".ts")):
                continue

            fName = os.path.basename(anArg)
            if not fName.startswith("nw_") and len(fName) > 6:
                print("Skipping non-novelWriter TS file %s" % fName)
                continue

            if os.path.isfile(anArg):
                tsList.append(anArg)
            elif os.path.exists(anArg):
                pass
            else:  # Create an empty new language file
                lCode = fName[3:-3]
                writeFile(anArg, (
                    "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
                    "<!DOCTYPE TS>\n"
                    f"<TS version=\"2.0\" language=\"{lCode}\" sourcelanguage=\"en_GB\"/>\n"
                ))
                tsList.append(anArg)

    else:
        print("No translation files selected for update ...")
        print("")
        return

    for aTS in tsList:
        print(aTS)

    print("")
    print("Updating Language Files:")
    print("")

    # Using the pylupdate tool from PyQt6 as it supports TS file format 2.1.
    lupdate(srcList, tsList, no_obsolete=True, no_summary=False)

    print("")

    return


##
#  Generage MacOS PList
##

def genMacOSPlist():
    """Set necessary values for .plist file for MacOS build."""
    outDir = "setup/macos"
    numVers = extractVersion()[0].partition("-")[0]
    copyrightYear = datetime.datetime.now().year

    # These keys are no longer used but are present for compatability
    pkgVersMaj, pkgVersMin = numVers.split(".")[:2]

    plistXML = readFile(f"{outDir}/Info.plist.template").format(
        macosBundleSVers=numVers,
        macosBundleVers=numVers,
        macosBundleVersMajor=pkgVersMaj,
        macosBundleVersMinor=pkgVersMin,
        macosBundleCopyright=f"Copyright 2018–{copyrightYear}, Veronica Berglyd Olsen",
    )

    print(f"Writing Info.plist to {outDir}/Info.plist")
    writeFile(f"{outDir}/Info.plist", plistXML)

    return


##
#  Sample Project ZIP File Builder (sample)
##

def buildSampleZip():
    """Bundle the sample project into a single zip file to be saved into
    the novelwriter/assets folder for further bundling into builds.
    """
    print("")
    print("Building Sample ZIP File")
    print("========================")
    print("")

    srcSample = "sample"
    dstSample = os.path.join("novelwriter", "assets", "sample.zip")

    if os.path.isdir(srcSample):
        if os.path.isfile(dstSample):
            os.unlink(dstSample)

        from zipfile import ZipFile

        with ZipFile(dstSample, "w") as zipObj:
            print("Compressing: nwProject.nwx")
            zipObj.write(os.path.join(srcSample, "nwProject.nwx"), "nwProject.nwx")
            for docFile in os.listdir(os.path.join(srcSample, "content")):
                print("Compressing: content/%s" % docFile)
                srcDoc = os.path.join(srcSample, "content", docFile)
                zipObj.write(srcDoc, "content/"+docFile)

    else:
        print("Error: Could not find sample project source directory.")
        sys.exit(1)

    print("")
    print("Built file: %s" % dstSample)
    print("")

    return


def cleanBuiltAssets():
    """Remove assets built by this script."""
    print("")
    print("Removing Built Assets")
    print("=====================")
    print("")

    sampleZip = os.path.join("novelwriter", "assets", "sample.zip")
    if os.path.isfile(sampleZip):
        print(f"Deleted: {sampleZip}")
        os.unlink(sampleZip)

    pdfManual = os.path.join("novelwriter", "assets", "manual.pdf")
    if os.path.isfile(pdfManual):
        print(f"Deleted: {pdfManual}")
        os.unlink(pdfManual)

    i18nAssets = os.path.join("novelwriter", "assets", "i18n")
    for i18nItem in os.listdir(i18nAssets):
        i18nPath = os.path.join(i18nAssets, i18nItem)
        if os.path.isfile(i18nPath) and i18nPath.endswith(".qm"):
            print(f"Deleted: {i18nPath}")
            os.unlink(i18nPath)

    print("")

    return


def checkAssetsExist():
    """Check that the necessary compiled assets exist ahead of a build.
    """
    hasSample = False
    hasManual = False
    hasQmData = False

    sampleZip = os.path.join("novelwriter", "assets", "sample.zip")
    if os.path.isfile(sampleZip):
        print(f"Found: {sampleZip}")
        hasSample = True

    pdfManual = os.path.join("novelwriter", "assets", "manual.pdf")
    if os.path.isfile(pdfManual):
        print(f"Found: {pdfManual}")
        hasManual = True

    i18nAssets = os.path.join("novelwriter", "assets", "i18n")
    for i18nItem in os.listdir(i18nAssets):
        i18nPath = os.path.join(i18nAssets, i18nItem)
        if os.path.isfile(i18nPath) and i18nPath.endswith(".qm"):
            print(f"Found: {i18nPath}")
            hasQmData = True

    return hasSample and hasManual and hasQmData


# =============================================================================================== #
#  Python Packaging
# =============================================================================================== #

##
#  Import Translations (import-i18n)
##

def importI18nUpdates(sysArgs):
    """Import new translation files from a zip file."""
    print("")
    print("Import Updated Translations")
    print("===========================")
    print("")

    fileName = None
    if len(sysArgs) >= 2:
        if os.path.isfile(sysArgs[1]):
            fileName = sysArgs[1]

    if fileName is None:
        print("File not found ...")
        sys.exit(1)

    projPath = os.path.join("novelwriter", "assets", "i18n")
    mainPath = "i18n"

    print("Loading file: %s" % fileName)
    with zipfile.ZipFile(fileName) as zipObj:
        for archFile in zipObj.namelist():
            if archFile.startswith("nw_") and archFile.endswith(".ts"):
                zipObj.extract(archFile, mainPath)
                print("Extracted: %s > %s" % (archFile, os.path.join(mainPath, archFile)))
            elif archFile.startswith("project_") and archFile.endswith(".json"):
                zipObj.extract(archFile, projPath)
                print("Extracted: %s > %s" % (archFile, os.path.join(projPath, archFile)))
            else:
                print("Skipped: %s" % archFile)

    print("")

    return


##
#  Make Minimal Package (minimal-zip)
##

def makeMinimalPackage(targetOS):
    """Pack the core source file in a single zip file."""
    from zipfile import ZipFile, ZIP_DEFLATED

    print("")
    print("Building Minimal ZIP File")
    print("=========================")

    bldDir = "dist_minimal"
    if not os.path.isdir(bldDir):
        os.mkdir(bldDir)

    if targetOS == OS_LINUX:
        targName = "-linux"
        print("Target OS: Linux")
    elif targetOS == OS_DARWIN:
        targName = "-darwin"
        print("Target OS: Darwin")
    elif targetOS == OS_WIN:
        targName = "-win"
        print("Target OS: Windows")
    else:
        targName = ""
    print("")

    # Check Additional Assets
    # =======================

    if not checkAssetsExist():
        print("ERROR: Missing build assets")
        sys.exit(1)

    # Build Minimal Zip
    # =================

    numVers, _, _ = extractVersion()
    pkgVers = compactVersion(numVers)
    zipFile = f"novelwriter-{pkgVers}-minimal{targName}.zip"
    outFile = os.path.join(bldDir, zipFile)
    if os.path.isfile(outFile):
        os.unlink(outFile)
    print("")

    rootFiles = [
        "README.md",
        "LICENSE.md",
        "CREDITS.md",
        "CHANGELOG.md",
        "requirements.txt",
        "pkgutils.py",
        "setup.cfg",
        "pyproject.toml",
    ]

    with ZipFile(outFile, "w", compression=ZIP_DEFLATED, compresslevel=9) as zipObj:

        for nRoot, _, nFiles in os.walk("novelwriter"):
            if nRoot.endswith("__pycache__"):
                print("Skipped: %s" % nRoot)
                continue

            print("Added: %s/* [Files: %d]" % (nRoot, len(nFiles)))
            for aFile in nFiles:
                if aFile.endswith(".pyc"):
                    print("Skipping File: %s" % aFile)
                    continue
                zipObj.write(os.path.join(nRoot, aFile))

        if targetOS == OS_WIN:
            zipObj.write("novelWriter.py", "novelWriter.pyw")
            print("Added: novelWriter.pyw")
            zipObj.write(os.path.join("setup", "windows_install.bat"), "windows_install.bat")
            print("Added: windows_install.bat")
            zipObj.write(os.path.join("setup", "windows_uninstall.bat"), "windows_uninstall.bat")
            print("Added: windows_uninstall.bat")

        else:  # Linux and Mac
            # Add icons
            for nRoot, _, nFiles in os.walk(os.path.join("setup", "data")):
                print("Added: %s/* [Files: %d]" % (nRoot, len(nFiles)))
                for aFile in nFiles:
                    zipObj.write(os.path.join(nRoot, aFile))

            zipObj.write(os.path.join("setup", "description_pypi.md"))
            print("Added: setup/description_pypi.md")

            zipObj.write("novelWriter.py")
            print("Added: novelWriter.py")

        for aFile in rootFiles:
            print("Added: %s" % aFile)
            zipObj.write(aFile)

        zipObj.write(os.path.join("novelwriter", "assets", "manual.pdf"), "UserManual.pdf")
        print("Added: UserManual.pdf")

    print("")
    print("Created File: %s" % outFile)

    # Create Checksum File
    # ====================

    shaFile = makeCheckSum(zipFile, cwd=bldDir)

    toUpload(outFile)
    toUpload(shaFile)

    print("")

    return


##
#  Make Debian Package (build-deb)
##

def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buildName=""):
    """Build a Debian package."""
    print("")
    print("Build Debian Package")
    print("====================")
    print("On Debian/Ubuntu install: dh-python python3-all debhelper devscripts ")
    print("                          pybuild-plugin-pyproject")
    print("")

    # Version Info
    # ============

    numVers, hexVers, relDate = extractVersion()
    pkgVers = compactVersion(numVers)
    relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d")
    pkgDate = email.utils.format_datetime(relDate.replace(hour=12, tzinfo=None))
    print("")

    if buildName:
        pkgVers = f"{pkgVers}{buildName}"

    # Set Up Folder
    # =============

    bldDir = "dist_deb"
    bldPkg = f"novelwriter_{pkgVers}"
    outDir = f"{bldDir}/{bldPkg}"
    debDir = f"{outDir}/debian"
    datDir = f"{outDir}/data"

    if not os.path.isdir(bldDir):
        os.mkdir(bldDir)

    if os.path.isdir(outDir):
        print("Removing old build files ...")
        print("")
        shutil.rmtree(outDir)

    os.mkdir(outDir)

    # Check Additional Assets
    # =======================

    if not checkAssetsExist():
        print("ERROR: Missing build assets")
        sys.exit(1)

    # Copy novelWriter Source
    # =======================

    print("Copying novelWriter source ...")
    print("")

    for nPath, _, nFiles in os.walk("novelwriter"):
        if nPath.endswith("__pycache__"):
            print("Skipped: %s" % nPath)
            continue

        pPath = f"{outDir}/{nPath}"
        if not os.path.isdir(pPath):
            os.mkdir(pPath)

        fCount = 0
        for fFile in nFiles:
            nFile = f"{nPath}/{fFile}"
            pFile = f"{pPath}/{fFile}"

            if fFile.endswith(".pyc"):
                print("Skipped: %s" % nFile)
                continue

            shutil.copyfile(nFile, pFile)
            fCount += 1

        print("Copied: %s/*  [Files: %d]" % (nPath, fCount))

    print("")
    print("Copying or generating additional files ...")
    print("")

    # Copy/Write Root Files
    # =====================

    copyFiles = ["LICENSE.md", "CREDITS.md", "CHANGELOG.md", "pyproject.toml"]
    for copyFile in copyFiles:
        shutil.copyfile(copyFile, f"{outDir}/{copyFile}")
        print("Copied: %s" % copyFile)

    writeFile(f"{outDir}/MANIFEST.in", (
        "include LICENSE.md\n"
        "include CREDITS.md\n"
        "include CHANGELOG.md\n"
        "include data/*\n"
        "recursive-include novelwriter/assets *\n"
    ))
    print("Wrote:  MANIFEST.in")

    writeFile(f"{outDir}/setup.py", (
        "import setuptools\n"
        "setuptools.setup()\n"
    ))
    print("Wrote:  setup.py")

    setupCfg = readFile("setup.cfg").replace(
        "file: setup/description_pypi.md", "file: data/description_short.txt"
    )
    writeFile(f"{outDir}/setup.cfg", setupCfg)
    print("Wrote:  setup.cfg")

    # Copy/Write Debian Files
    # =======================

    shutil.copytree("setup/debian", debDir)
    print("Copied: debian/*")

    writeFile(f"{debDir}/changelog", (
        f"novelwriter ({pkgVers}) {distName}; urgency=low\n\n"
        f"  * Update to version {pkgVers}\n\n"
        f" -- Veronica Berglyd Olsen <code@vkbo.net>  {pkgDate}\n"
    ))
    print("Wrote:  debian/changelog")

    # Copy/Write Data Files
    # =====================

    shutil.copytree("setup/data", datDir)
    print("Copied: data/*")

    shutil.copyfile("setup/description_short.txt", f"{outDir}/data/description_short.txt")
    print("Copied: data/description_short.txt")

    # Build Package
    # =============

    print("")
    print("Running dpkg-buildpackage ...")
    print("")

    if signKey is None:
        signArgs = ["-us", "-uc"]
    else:
        signArgs = [f"-k{signKey}"]

    if sourceBuild:
        subprocess.call(["debuild", "-S"] + signArgs, cwd=outDir)
        toUpload(f"{bldDir}/{bldPkg}.tar.xz")
    else:
        subprocess.call(["dpkg-buildpackage"] + signArgs, cwd=outDir)
        shutil.copyfile(f"{bldDir}/{bldPkg}.tar.xz", f"{bldDir}/{bldPkg}.debian.tar.xz")
        toUpload(f"{bldDir}/{bldPkg}.debian.tar.xz")
        toUpload(f"{bldDir}/{bldPkg}_all.deb")

        toUpload(makeCheckSum(f"{bldPkg}.debian.tar.xz", cwd=bldDir))
        toUpload(makeCheckSum(f"{bldPkg}_all.deb", cwd=bldDir))

    print("")
    print("Done!")
    print("")

    if sourceBuild:
        if hexVers[-2] == "f":
            ppaName = "novelwriter"
        else:
            ppaName = "novelwriter-pre"

        return f"dput {ppaName}/{distName} {bldDir}/{bldPkg}_source.changes"

    return ""


##
#  Make Launchpad Package (build-ubuntu)
##

def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False):
    """Wrapper for building Debian packages for Launchpad."""
    print("")
    print("Launchpad Packages")
    print("==================")
    print("")

    if isFirst or isSnapshot:
        bldNum = "0"
    else:
        bldNum = input("Build number [0]: ")
        if bldNum == "":
            bldNum = "0"

    distLoop = [
        ("20.04", "focal"),
        ("22.04", "jammy"),
        ("23.04", "lunar"),
        ("23.10", "mantic"),
    ]

    tStamp = datetime.datetime.now().strftime("%Y%m%d~%H%M%S")
    if isSnapshot:
        print(f"Building Ununtu SNAPSHOT~{tStamp} for:")
        print("")
    else:
        print("Building Ubuntu packages for:")
        print("")
    for distNum, codeName in distLoop:
        print(f" * Ubuntu {distNum} {codeName.title()}")
    print("")

    if doSign:
        signKey = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08"
    else:
        signKey = None

    print(f"Sign Key: {str(signKey)}")
    print("")

    dputCmd = []
    for distNum, codeName in distLoop:
        if isSnapshot:
            buildName = f"+SNAPSHOT~{tStamp}~ubuntu{distNum}.0"
        else:
            buildName = f"~ubuntu{distNum}.{bldNum}"

        dCmd = makeDebianPackage(
            signKey=signKey,
            sourceBuild=True,
            distName=codeName,
            buildName=buildName
        )
        dputCmd.append(dCmd)

    print("Packages Built")
    print("==============")
    print("")
    for dCmd in dputCmd:
        print(f" > {dCmd}")
    print("")

    return


##
#  Make AppImage (build-appimage)
##

def makeAppImage(sysArgs):
    """Build an AppImage."""
    import glob
    import argparse

    try:
        import python_appimage  # noqa F401
    except ImportError:
        print(
            "ERROR: Package 'python-appimage' is missing on this system.\n"
            "       Please run 'pip install --user python-appimage' to install it.\n"
        )
        sys.exit(1)

    print("")
    print("Build AppImage")
    print("==============")
    print("")

    parser = argparse.ArgumentParser(
        prog="build_appimage",
        description="Build an AppImage",
        epilog="see https://appimage.org/ for more details",
    )
    parser.add_argument(
        "--linux-tag",
        nargs="?",
        default="manylinux_2_28_x86_64",
        help=(
            "Linux compatibility tag (e.g. manylinux_2_28_x86_64)\n"
            "see https://python-appimage.readthedocs.io/en/latest/#available-python-appimages \n"
            "and https://github.com/pypa/manylinux for a list of valid tags"
        ),
    )
    parser.add_argument(
        "--python-version", nargs="?", default="3.11", help="Python version (e.g. 3.11)"
    )

    args, unparsedArgs = parser.parse_known_args(sysArgs)

    linuxTag = args.linux_tag
    pythonVer = args.python_version

    # Version Info
    # ============

    numVers, _, relDate = extractVersion()
    pkgVers = compactVersion(numVers)
    relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d")
    print("")

    # Set Up Folder
    # =============

    bldDir = "dist_appimage"
    bldPkg = f"novelwriter_{pkgVers}"
    outDir = f"{bldDir}/{bldPkg}"
    imageDir = f"{bldDir}/appimage"

    # Set Up Folders
    # ==============

    if not os.path.isdir(bldDir):
        os.mkdir(bldDir)

    if os.path.isdir(outDir):
        print("Removing old build files ...")
        print("")
        shutil.rmtree(outDir)

    os.mkdir(outDir)

    if os.path.isdir(imageDir):
        print("Removing old build metadata files ...")
        print("")
        shutil.rmtree(imageDir)

    os.mkdir(imageDir)

    # Remove old AppImages
    outFiles = glob.glob(f"{bldDir}/*.AppImage")
    if outFiles:
        print("Removing old AppImages")
        print("")
        for image in outFiles:
            try:
                os.remove(image)
            except OSError:
                print("Error while deleting file : ", image)

    # Copy novelWriter Source
    # =======================

    print("Copying novelWriter source ...")
    print("")

    for nPath, _, nFiles in os.walk("novelwriter"):
        if nPath.endswith("__pycache__"):
            print("Skipped: %s" % nPath)
            continue

        pPath = f"{outDir}/{nPath}"
        if not os.path.isdir(pPath):
            os.mkdir(pPath)

        fCount = 0
        for fFile in nFiles:
            nFile = f"{nPath}/{fFile}"
            pFile = f"{pPath}/{fFile}"

            if fFile.endswith(".pyc"):
                print("Skipped: %s" % nFile)
                continue

            shutil.copyfile(nFile, pFile)
            fCount += 1

        print("Copied: %s/*  [Files: %d]" % (nPath, fCount))

    print("")
    print("Copying or generating additional files ...")
    print("")

    # Copy/Write Root Files
    # =====================

    copyFiles = ["LICENSE.md", "CREDITS.md", "CHANGELOG.md", "pyproject.toml"]
    for copyFile in copyFiles:
        shutil.copyfile(copyFile, f"{outDir}/{copyFile}")
        print("Copied: %s" % copyFile)

    writeFile(f"{outDir}/MANIFEST.in", (
        "include LICENSE.md\n"
        "include CREDITS.md\n"
        "include CHANGELOG.md\n"
        "include data/*\n"
        "recursive-include novelwriter/assets *\n"
    ))
    print("Wrote:  MANIFEST.in")

    writeFile(f"{outDir}/setup.py", (
        "import setuptools\n"
        "setuptools.setup()\n"
    ))
    print("Wrote:  setup.py")

    setupCfg = readFile("setup.cfg").replace(
        "file: setup/description_pypi.md", "file: data/description_short.txt"
    )
    writeFile(f"{outDir}/setup.cfg", setupCfg)
    print("Wrote:  setup.cfg")

    # Write Metadata
    # ==============

    appDescription = readFile("setup/description_short.txt")
    appdataXML = readFile("setup/novelwriter.appdata.xml").format(description=appDescription)
    writeFile(f"{imageDir}/novelwriter.appdata.xml", appdataXML)
    print("Wrote:  novelwriter.appdata.xml")

    writeFile(f"{imageDir}/entrypoint.sh", (
        '#! /bin/bash \n'
        '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"'
    ))
    print("Wrote:  entrypoint.sh")

    writeFile(f"{imageDir}/requirements.txt", os.path.abspath(outDir))
    print("Wrote:  requirements.txt")

    shutil.copyfile("setup/data/novelwriter.desktop", f"{imageDir}/novelwriter.desktop")
    print("Copied: novelwriter.desktop")

    shutil.copyfile("setup/icons/novelwriter.svg", f"{imageDir}/novelwriter.svg")
    print("Copied: novelwriter.svg")

    shutil.copyfile(
        "setup/data/hicolor/256x256/apps/novelwriter.png", f"{imageDir}/novelwriter.png"
    )
    print("Copied: novelwriter.png")

    # Build AppImage
    # ==============

    try:
        subprocess.call([
            sys.executable, "-m", "python_appimage", "build", "app",
            "-l", linuxTag, "-p", pythonVer, "appimage"
        ], cwd=bldDir)
    except Exception as exc:
        print("AppImage build: FAILED")
        print("")
        print(str(exc))
        print("")
        sys.exit(1)

    linuxLabel = ""
    if not linuxTag.startswith("manylinux_2_28"):
        linuxLabel = "-oldlinux"
        if not linuxTag.endswith("x86_64"):
            # manylinux_2_28 only comes in 64 bit, so this one only applies to older images
            linuxLabel += "-32bit"

    bldFile = glob.glob(f"{bldDir}/*.AppImage")[0]
    outFile = f"{bldDir}/novelWriter-{pkgVers}{linuxLabel}.AppImage"
    os.rename(bldFile, outFile)
    shaFile = makeCheckSum(os.path.basename(outFile), cwd=bldDir)

    toUpload(outFile)
    toUpload(shaFile)

    return unparsedArgs


##
#  Make Windows Setup EXE (build-win-exe)
##

def makeWindowsEmbedded(sysArgs):
    """Set up a package with embedded Python and dependencies for
    Windows installation.
    """
    import urllib.request
    import zipfile
    import compileall

    print("")
    print("Build Standalone Windows Package")
    print("================================")
    print("")

    minimalZip = None
    packVersion = "none"
    if len(sysArgs) >= 2:
        if os.path.isfile(sysArgs[1]):
            minimalZip = sysArgs[1]

    if minimalZip is None:
        print("Please provide the path to the minimal win package as an argument")
        sys.exit(1)

    packVersion = os.path.basename(minimalZip).split("-")[1]
    print("Version: %s" % packVersion)

    # Set Up Folder
    # =============

    if not os.path.isdir("dist"):
        os.mkdir("dist")

    outDir = os.path.join("dist", "novelWriter")
    libDir = os.path.join(outDir, "lib")
    if os.path.isdir(outDir):
        shutil.rmtree(outDir)

    os.mkdir(outDir)
    os.mkdir(libDir)

    # Extract Source Files
    # ====================

    print("Extracting source files ...")
    with zipfile.ZipFile(minimalZip, "r") as inFile:
        inFile.extractall(outDir)

    shutil.copyfile(
        os.path.join(outDir, "novelwriter", "assets", "icons", "novelwriter.ico"),
        os.path.join(outDir, "novelwriter.ico")
    )

    compileall.compile_dir(os.path.join(outDir, "novelwriter"))

    print("Done")
    print("")

    # Download Python Embeddable
    # ==========================

    print("Adding Python embeddable ...")

    pyVers = "%d.%d.%d" % (sys.version_info[:3])
    zipFile = "python-%s-embed-amd64.zip" % pyVers
    pyZip = os.path.join("dist", zipFile)
    if not os.path.isfile(pyZip):
        pyUrl = f"https://www.python.org/ftp/python/{pyVers}/{zipFile}"
        print("Downloading: %s" % pyUrl)
        urllib.request.urlretrieve(pyUrl, pyZip)

    print("Extracting ...")
    with zipfile.ZipFile(pyZip, "r") as inFile:
        inFile.extractall(outDir)

    print("Done")
    print("")

    # Sort Out Licence Files
    # ======================

    os.rename(
        os.path.join(outDir, "LICENSE.txt"),
        os.path.join(outDir, "PYTHON-LICENSE.txt")
    )
    shutil.copyfile(
        os.path.join("setup", "iss_license.txt"),
        os.path.join(outDir, "LICENSES.txt")
    )

    # Install Dependencies
    # ====================

    print("Install dependencies ...")

    sysCmd  = [sys.executable]
    sysCmd += "-m pip install -r requirements.txt --target".split()
    sysCmd += [libDir]
    try:
        subprocess.call(sysCmd)
    except Exception as exc:
        print("Failed with error:")
        print(str(exc))
        sys.exit(1)

    print("Done")
    print("")

    # Update Launch File
    # ==================

    print("Updating starting script ...")

    writeFile(os.path.join(outDir, "novelWriter.pyw"), (
        "#!/usr/bin/env python3\n"
        "import os\n"
        "import sys\n"
        "\n"
        "os.curdir = os.path.abspath(os.path.dirname(__file__))\n"
        "sys.path.insert(0, os.path.join(os.curdir, \"lib\"))\n"
        "\n"
        "if __name__ == \"__main__\":\n"
        "    import novelwriter\n"
        "    novelwriter.main(sys.argv[1:])\n"
    ))

    print("Done")
    print("")

    # Clean Up Files
    # ==============

    def unlinkIfFound(delFile):
        if os.path.isfile(delFile):
            os.unlink(delFile)
            print("Deleted: %s" % delFile)

    def deleteFolder(delPath):
        if os.path.isdir(delPath):
            shutil.rmtree(delPath)
            print("Deleted: %s" % delPath)

    print("Deleting Redundant Files")
    print("========================")
    print("")

    pyQt5Dir = os.path.join(libDir, "PyQt5")
    bindDir  = os.path.join(pyQt5Dir, "bindings")
    qt5Dir   = os.path.join(pyQt5Dir, "Qt5")
    binDir   = os.path.join(qt5Dir, "bin")
    plugDir  = os.path.join(qt5Dir, "plugins")
    qmDir    = os.path.join(qt5Dir, "translations")
    dictDir  = os.path.join(libDir, "enchant", "data", "mingw64", "share", "enchant", "hunspell")

    for dictFile in os.listdir(dictDir):
        if not dictFile.startswith(("en_GB", "en_US")):
            unlinkIfFound(os.path.join(dictDir, dictFile))

    for qmFile in os.listdir(qmDir):
        if not qmFile.startswith("qtbase"):
            unlinkIfFound(os.path.join(qmDir, qmFile))

    delQt5 = [
        "Qt5Bluetooth", "Qt5DBus", "Qt5Designer", "Qt5Designer", "Qt5Help", "Qt5Location",
        "Qt5Multimedia", "Qt5MultimediaWidgets", "Qt5Network", "Qt5Nfc", "Qt5OpenGL",
        "Qt5Positioning", "Qt5PositioningQuick", "Qt5Qml", "Qt5QmlModels", "Qt5QmlWorkerScript",
        "Qt5Quick", "Qt5Quick3D", "Qt5Quick3DAssetImport", "Qt5Quick3DRender",
        "Qt5Quick3DRuntimeRender", "Qt5Quick3DUtils", "Qt5QuickControls2", "Qt5QuickParticles",
        "Qt5QuickShapes", "Qt5QuickTemplates2", "Qt5QuickTest", "Qt5QuickWidgets", "Qt5Sensors",
        "Qt5SerialPort", "Qt5Sql", "Qt5Test", "Qt5TextToSpeech", "Qt5WebChannel", "Qt5WebSockets",
        "Qt5WebView", "Qt5Xml", "Qt5XmlPatterns"
    ]
    for qt5Item in delQt5:
        qtItem = qt5Item.replace("Qt5", "Qt")
        unlinkIfFound(os.path.join(binDir, qt5Item+".dll"))
        unlinkIfFound(os.path.join(pyQt5Dir, qtItem+".pyd"))
        unlinkIfFound(os.path.join(pyQt5Dir, qtItem+".pyi"))
        deleteFolder(os.path.join(bindDir, qtItem))

    delList = [
        os.path.join(binDir, "opengl32sw.dll"),
        os.path.join(qt5Dir, "qml"),
        os.path.join(plugDir, "geoservices"),
        os.path.join(plugDir, "playlistformats"),
        os.path.join(plugDir, "renderers"),
        os.path.join(plugDir, "sensorgestures"),
        os.path.join(plugDir, "sensors"),
        os.path.join(plugDir, "sqldrivers"),
        os.path.join(plugDir, "texttospeech"),
        os.path.join(plugDir, "webview"),
    ]
    for delItem in delList:
        if os.path.isfile(delItem):
            unlinkIfFound(delItem)
        elif os.path.isdir(delItem):
            deleteFolder(delItem)

    print("Done")
    print("")

    print("Running Inno Setup")
    print("##################")
    print("")

    # Read the iss template
    issData = readFile(os.path.join("setup", "win_setup_embed.iss"))
    issData = issData.replace(r"%%version%%", packVersion)
    issData = issData.replace(r"%%dir%%", os.getcwd())
    writeFile("setup.iss", issData)
    print("")

    try:
        subprocess.call(["iscc", "setup.iss"])
    except Exception as exc:
        print("Inno Setup failed with error:")
        print(str(exc))
        sys.exit(1)

    print("")
    print("Done")
    print("")

    return


# =============================================================================================== #
#  General Installers
# =============================================================================================== #

##
#  XDG Installation (xdg-install)
##

def xdgInstall():
    """Will attempt to install icons and make a launcher."""
    print("")
    print("XDG Install")
    print("===========")
    print("")

    # Find Executable(s)
    # ==================

    exOpts = []

    testExec = shutil.which("novelWriter")
    if testExec is not None:
        exOpts.append(testExec)

    testExec = shutil.which("novelwriter")
    if testExec is not None:
        exOpts.append(testExec)

    testExec = os.path.join(os.getcwd(), "novelWriter.py")
    if os.path.isfile(testExec):
        exOpts.append(testExec)

    useExec = ""
    nOpts = len(exOpts)
    if nOpts == 0:
        print("Error: No executables for novelWriter found.")
        sys.exit(1)
    elif nOpts == 1:
        useExec = exOpts[0]
    else:
        print("Found multiple novelWriter executables:")
        print("")
        for iExec, anExec in enumerate(exOpts):
            print(" [%d] %s" % (iExec, anExec))
        print("")
        intVal = int(input("Please select which novelWriter executable to use: "))
        print("")

        if intVal >= 0 and intVal < nOpts:
            useExec = exOpts[intVal]
        else:
            print("Error: Invalid selection.")
            sys.exit(1)

    print("Using executable: %s " % useExec)
    print("")

    # Create and Install Launcher
    # ===========================

    # Generate launcher
    desktopData = readFile(os.path.join("setup", "data", "novelwriter.desktop"))
    desktopData = desktopData.replace("Exec=novelwriter", f"Exec={useExec}")
    writeFile("novelwriter.desktop", desktopData)

    # Remove old desktop icon
    exCode = subprocess.call(
        ["xdg-desktop-icon", "uninstall", "novelwriter.desktop"]
    )

    # Install application launcher
    exCode = subprocess.call(
        ["xdg-desktop-menu", "install", "--novendor", "novelwriter.desktop"]
    )
    if exCode == 0:
        print("Installed menu launcher file")
    else:
        print(f"Error {exCode}: Could not install menu launcher file")

    # Install MimeType
    # ================

    exCode = subprocess.call([
        "xdg-mime", "install", "setup/data/x-novelwriter-project.xml"
    ])
    if exCode == 0:
        print("Installed mimetype")
    else:
        print(f"Error {exCode}: Could not install mimetype")

    # Install Icons
    # =============

    iconRoot = "setup/data/hicolor"
    sizeArr = ["16", "24", "32", "48", "64", "128", "256"]

    # App Icon
    for aSize in sizeArr:
        exCode = subprocess.call([
            "xdg-icon-resource", "install", "--novendor", "--noupdate",
            "--context", "apps", "--size", aSize,
            f"{iconRoot}/{aSize}x{aSize}/apps/novelwriter.png",
            "novelwriter"
        ])
        if exCode == 0:
            print(f"Installed app icon size {aSize}")
        else:
            print(f"Error {exCode}: Could not install app icon size {aSize}")

    # Mimetype
    for aSize in sizeArr:
        exCode = subprocess.call([
            "xdg-icon-resource", "install", "--noupdate",
            "--context", "mimetypes", "--size", aSize,
            f"{iconRoot}/{aSize}x{aSize}/mimetypes/application-x-novelwriter-project.png",
            "application-x-novelwriter-project"
        ])
        if exCode == 0:
            print(f"Installed mime icon size {aSize}")
        else:
            print(f"Error {exCode}: Could not install mime icon size {aSize}")

    # Update Cache
    exCode = subprocess.call(["xdg-icon-resource", "forceupdate"])
    if exCode == 0:
        print("Updated icon cache")
    else:
        print(f"Error {exCode}: Could not update icon cache")

    # Clean up
    if os.path.isfile("./novelwriter.desktop"):
        os.unlink("./novelwriter.desktop")

    print("")
    print("Done!")
    print("")

    return


##
#  XDG Uninstallation (xdg-uninstall)
##

def xdgUninstall():
    """Will attempt to uninstall icons and the launcher."""
    print("")
    print("XDG Uninstall")
    print("=============")
    print("")

    # Application Menu Icon
    exCode = subprocess.call(
        ["xdg-desktop-menu", "uninstall", "novelwriter.desktop"]
    )
    if exCode == 0:
        print("Uninstalled menu launcher file")
    else:
        print(f"Error {exCode}: Could not uninstall menu launcher file")

    # Desktop Icon
    # (No longer installed)
    exCode = subprocess.call(
        ["xdg-desktop-icon", "uninstall", "novelwriter.desktop"]
    )
    if exCode == 0:
        print("Uninstalled desktop launcher file")
    else:
        print(f"Error {exCode}: Could not uninstall desktop launcher file")

    # Also include no longer used sizes
    sizeArr = ["16", "22", "24", "32", "48", "64", "96", "128", "256", "512"]

    # App Icons
    for aSize in sizeArr:
        exCode = subprocess.call([
            "xdg-icon-resource", "uninstall", "--noupdate",
            "--context", "apps", "--size", aSize, "novelwriter"
        ])
        if exCode == 0:
            print(f"Uninstalled app icon size {aSize}")
        else:
            print(f"Error {exCode}: Could not uninstall app icon size {aSize}")

    # Mimetype
    for aSize in sizeArr:
        exCode = subprocess.call([
            "xdg-icon-resource", "uninstall", "--noupdate",
            "--context", "mimetypes", "--size", aSize,
            "application-x-novelwriter-project"
        ])
        if exCode == 0:
            print(f"Uninstalled mime icon size {aSize}")
        else:
            print(f"Error {exCode}: Could not uninstall mime icon size {aSize}")

    # Update Cache
    exCode = subprocess.call(["xdg-icon-resource", "forceupdate"])
    if exCode == 0:
        print("Updated icon cache")
    else:
        print(f"Error {exCode}: Could not update icon cache")

    print("")
    print("Done!")
    print("")

    return


##
#  WIN Installation (win-install)
##

def winInstall():
    """Will attempt to install icons and make a launcher for Windows."""
    import winreg
    try:
        import win32com.client
    except ImportError:
        print(
            "ERROR: Package 'pywin32' is missing on this system.\n"
            "       Please run 'pkgutils.py pip' to automatically install\n"
            "       dependecies, or run 'pip install --user pywin32'."
        )
        sys.exit(1)

    print("")
    print("Windows Install")
    print("===============")
    print("")

    numVers, hexVers, _ = extractVersion()
    nwTesting = not hexVers[-2] == "f"
    wShell = win32com.client.Dispatch("WScript.Shell")

    if nwTesting:
        linkName = "novelWriter Testing %s.lnk" % numVers
    else:
        linkName = "novelWriter %s.lnk" % numVers

    desktopDir = wShell.SpecialFolders("Desktop")
    desktopIcon = os.path.join(desktopDir, linkName)

    startMenuDir = wShell.SpecialFolders("StartMenu")
    startMenuProg = os.path.join(startMenuDir, "Programs", "novelWriter")
    startMenuIcon = os.path.join(startMenuProg, linkName)

    pythonDir = os.path.dirname(sys.executable)
    pythonExe = os.path.join(pythonDir, "pythonw.exe")

    targetDir = os.path.abspath(os.path.dirname(__file__))
    targetPy = os.path.join(targetDir, "novelWriter.pyw")
    targetIcon = os.path.join(targetDir, "novelwriter", "assets", "icons", "novelwriter.ico")

    if not os.path.isfile(targetPy):
        shutil.copy2(os.path.join(targetDir, "novelWriter.py"), targetPy)

    print("")
    print("Collecting Info ...")
    print("Desktop Folder:    %s" % desktopDir)
    print("Start Menu Folder: %s" % startMenuDir)
    print("Python Executable: %s" % pythonExe)
    print("Target Executable: %s" % targetPy)
    print("Target Icon:       %s" % targetIcon)
    print("")

    print("Creating Links ...")
    if os.path.isfile(desktopIcon):
        os.unlink(desktopIcon)
        print("Deleted: %s" % desktopIcon)

    if os.path.isdir(startMenuProg):
        for oldIcon in os.listdir(startMenuProg):
            oldPath = os.path.join(startMenuProg, oldIcon)
            if not oldIcon.startswith("novelWriter"):
                continue

            isTesting = oldIcon.startswith("novelWriter Testing")
            if isTesting and nwTesting:
                os.unlink(oldPath)
                print("Deleted: %s" % oldPath)
            if not isTesting and not nwTesting:
                os.unlink(oldPath)
                print("Deleted: %s" % oldPath)

    else:
        os.mkdir(startMenuProg)
        print("Created: %s" % startMenuProg)

    wShortcut = wShell.CreateShortCut(desktopIcon)
    wShortcut.TargetPath = targetPy
    wShortcut.WorkingDirectory = targetDir
    wShortcut.IconLocation = targetIcon
    wShortcut.WindowStyle = 1
    wShortcut.save()
    print("Created: %s" % desktopIcon)

    wShortcut = wShell.CreateShortCut(startMenuIcon)
    wShortcut.TargetPath = targetPy
    wShortcut.WorkingDirectory = targetDir
    wShortcut.IconLocation = targetIcon
    wShortcut.WindowStyle = 1
    wShortcut.save()
    print("Created: %s" % startMenuIcon)

    print("")
    print("Creating registry keys ...")

    def setKey(kPath, kName, kVal):
        winreg.CreateKey(winreg.HKEY_CURRENT_USER, kPath)
        regKey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, kPath, 0, winreg.KEY_WRITE)
        winreg.SetValueEx(regKey, kName, 0, winreg.REG_SZ, kVal)
        winreg.CloseKey(regKey)

    mimeIcon = os.path.join(
        targetDir, "novelwriter", "assets", "icons", "x-novelwriter-project.ico"
    )
    mimeExec = '"%s" "%s" "%%1"' % (pythonExe, targetPy)

    try:
        setKey(r"Software\Classes\.nwx\OpenWithProgids", "novelWriterProject.nwx", "")
        setKey(r"Software\Classes\novelWriterProject.nwx", "", "novelWriter Project File")
        setKey(r"Software\Classes\novelWriterProject.nwx\DefaultIcon", "", mimeIcon)
        setKey(r"Software\Classes\novelWriterProject.nwx\shell\open\command", "", mimeExec)
        setKey(r"Software\Classes\Applications\novelWriter.pyw\SupportedTypes", ".nwx", "")
    except WindowsError:
        print("ERROR: Failed to set registry keys.")
        print("")

    print("")
    print("Done!")
    print("")

    return


##
#  WIN Uninstallation (win-uninstall)
##

def winUninstall():
    """Will attempt to uninstall icons previously installed."""
    import winreg
    try:
        import win32com.client
    except ImportError:
        print(
            "ERROR: Package 'pywin32' is missing on this system.\n"
            "       Please run 'pip install --user pywin32' to install it."
        )
        sys.exit(1)

    print("")
    print("Windows Uninstall")
    print("=================")
    print("")

    numVers, hexVers, _ = extractVersion()
    nwTesting = not hexVers[-2] == "f"
    wShell = win32com.client.Dispatch("WScript.Shell")

    if nwTesting:
        linkName = "novelWriter Testing %s.lnk" % numVers
    else:
        linkName = "novelWriter %s.lnk" % numVers

    desktopDir = wShell.SpecialFolders("Desktop")
    desktopIcon = os.path.join(desktopDir, linkName)

    startMenuDir = wShell.SpecialFolders("StartMenu")
    startMenuProg = os.path.join(startMenuDir, "Programs", "novelWriter")
    startMenuIcon = os.path.join(startMenuProg, linkName)

    print("")
    print("Deleting Links ...")
    if os.path.isfile(desktopIcon):
        os.unlink(desktopIcon)
        print("Deleted: %s" % desktopIcon)
    else:
        print("Not Found: %s" % desktopIcon)

    if os.path.isfile(startMenuIcon):
        os.unlink(startMenuIcon)
        print("Deleted: %s" % startMenuIcon)
    else:
        print("Not Found: %s" % startMenuIcon)

    if os.path.isdir(startMenuProg):
        if not os.listdir(startMenuProg):
            os.rmdir(startMenuProg)
            print("Deleted: %s" % startMenuProg)
        else:
            print("Not Empty: %s" % startMenuProg)

    print("")
    print("Removing registry keys ...")

    theKeys = [
        r"Software\Classes\novelWriterProject.nwx\shell\open\command",
        r"Software\Classes\novelWriterProject.nwx\shell\open",
        r"Software\Classes\novelWriterProject.nwx\shell",
        r"Software\Classes\novelWriterProject.nwx\DefaultIcon",
        r"Software\Classes\novelWriterProject.nwx",
        r"Software\Classes\.nwx\OpenWithProgids",
        r"Software\Classes\.nwx",
        r"Software\Classes\Applications\novelWriter.pyw\SupportedTypes",
        r"Software\Classes\Applications\novelWriter.pyw",
    ]

    for aKey in theKeys:
        try:
            winreg.DeleteKey(winreg.HKEY_CURRENT_USER, aKey)
            print("Deleted: HKEY_CURRENT_USER\\%s" % aKey)
        except WindowsError:
            print("Not Found: HKEY_CURRENT_USER\\%s" % aKey)

    print("")
    print("Done!")
    print("")

    return


# =============================================================================================== #
#  Process Command Line
# =============================================================================================== #

if __name__ == "__main__":
    """Parse command line options and run the commands."""
    # Detect OS
    if sys.platform.startswith("linux"):
        hostOS = OS_LINUX
    elif sys.platform.startswith("darwin"):
        hostOS = OS_DARWIN
    elif sys.platform.startswith("win32"):
        hostOS = OS_WIN
    elif sys.platform.startswith("cygwin"):
        hostOS = OS_WIN
    else:
        hostOS = OS_NONE

    # Set Target OS
    if "--target-linux" in sys.argv:
        sys.argv.remove("--target-linux")
        targetOS = OS_LINUX
    elif "--target-darwin" in sys.argv:
        sys.argv.remove("--target-darwin")
        targetOS = OS_DARWIN
    elif "--target-win" in sys.argv:
        sys.argv.remove("--target-win")
        targetOS = OS_WIN
    else:
        targetOS = hostOS

    # Sign package
    if "--sign" in sys.argv:
        sys.argv.remove("--sign")
        doSign = True
    else:
        doSign = False

    # First build
    if "--first" in sys.argv:
        sys.argv.remove("--first")
        isFirstBuild = True
    else:
        isFirstBuild = False

    # Build snapshot
    if "--snapshot" in sys.argv:
        sys.argv.remove("--snapshot")
        isSnapshot = True
    else:
        isSnapshot = False

    helpMsg = [
        "",
        "novelWriter Setup Tool",
        "======================",
        "",
        "This tool provides setup and build commands for installing or distibuting",
        "novelWriter as a package on Linux, Mac and Windows. The available options",
        "are as follows:",
        "",
        "Some of the commands can be targeted towards a different OS than the host OS.",
        "To target the command, add one of '--target-linux', '--target-darwin' or",
        "'--target-win'.",
        "",
        "General:",
        "",
        "    help           Print the help message.",
        "    pip            Install all package dependencies for novelWriter using pip.",
        "    version        Print the novelWriter version. Add -c for short version.",
        "    build-clean    Will attempt to delete 'build' and 'dist' folders.",
        "",
        "Additional Builds:",
        "",
        "    manual         Build the help documentation as PDF (requires LaTeX).",
        "    sample         Build the sample project zip file and add it to assets.",
        "    qtlupdate      Update translation files for internationalisation.",
        "                   The files to be updated must be provided as arguments.",
        "    qtlrelease     Build the language files for internationalisation.",
        "    clean-assets   Delete assets built by manual, sample and qtlrelease.",
        "    gen-plist      Generates an Info.plist for use in a MacOS Bundle",
        "",
        "Python Packaging:",
        "",
        "    import-i18n    Import updated i18n files from a zip file.",
        "    minimal-zip    Creates a minimal zip file of the core application without",
        "                   all the other source files. Defaults to tailor the zip file",
        "                   for the current OS, but accepts a target OS flag to build",
        "                   for another OS.",
        "    build-deb      Build a .deb package for Debian and Ubuntu. Add --sign to ",
        "                   sign package.",
        "    build-ubuntu   Build a .deb packages Launchpad. Add --sign to ",
        "                   sign package. Add --first to set build number to 0.",
        "                   Add --snapshot to make a snapshot package.",
        "    build-win-exe  Build a setup.exe file with Python embedded for Windows.",
        "                   The package must be built from a minimal windows zip file.",
        "    build-appimage Build an AppImage. Argument --linux-tag defaults to",
        "                   manylinux_2_28_x86_64, and --python-version to 3.11.",
        "",
        "System Install:",
        "",
        "    install        Deprecated. Use pip or build.",
        "    xdg-install    Install launcher and icons for freedesktop systems. Run as",
        "                   root or with sudo for system-wide install, or as user for",
        "                   single user install.",
        "    xdg-uninstall  Remove the launcher and icons for the current system as",
        "                   installed by the 'xdg-install' command.",
        "    win-install    Install desktop icon, start menu icon, and registry entries",
        "                   for file association with .nwx files for Windows systems.",
        "    win-uninstall  Remove desktop icon, start menu icon, and registry keys,",
        "                   for the current system. Note that it only removes icons for",
        "                   the version number of the package the command is run from.",
        "",
    ]

    # General
    # =======

    if "help" in sys.argv:
        sys.argv.remove("help")
        print("\n".join(helpMsg))
        sys.exit(0)

    if "version" in sys.argv:
        sys.argv.remove("version")
        numVers, _, _ = extractVersion(beQuiet=True)
        if "-c" in sys.argv:
            sys.argv.remove("-c")
            print(compactVersion(numVers), end=None)
        else:
            print(numVers, end=None)
        sys.exit(0)

    if "pip" in sys.argv:
        sys.argv.remove("pip")
        installPackages(hostOS)

    if "build-clean" in sys.argv:
        sys.argv.remove("build-clean")
        cleanBuildDirs()

    # Additional Builds
    # =================

    if "manual" in sys.argv:
        sys.argv.remove("manual")
        buildPdfManual()

    if "qtlrelease" in sys.argv:
        sys.argv.remove("qtlrelease")
        buildQtI18n()

    if "qtlupdate" in sys.argv:
        sys.argv.remove("qtlupdate")
        buildQtI18nTS(sys.argv)
        sys.exit(0)  # Don't continue execution

    if "sample" in sys.argv:
        sys.argv.remove("sample")
        buildSampleZip()

    if "clean-assets" in sys.argv:
        sys.argv.remove("clean-assets")
        cleanBuiltAssets()

    if "gen-plist" in sys.argv:
        sys.argv.remove("gen-plist")
        genMacOSPlist()

    # Python Packaging
    # ================

    if "import-i18n" in sys.argv:
        sys.argv.remove("import-i18n")
        importI18nUpdates(sys.argv)
        sys.exit(0)  # Don't continue execution

    if "minimal-zip" in sys.argv:
        sys.argv.remove("minimal-zip")
        makeMinimalPackage(targetOS)

    if "build-deb" in sys.argv:
        sys.argv.remove("build-deb")
        if hostOS == OS_LINUX:
            if doSign:
                signKey = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08"
            else:
                signKey = None
            makeDebianPackage(signKey=signKey)
        else:
            print("ERROR: Command 'build-deb' can only be used on Linux")
            sys.exit(1)

    if "build-ubuntu" in sys.argv:
        sys.argv.remove("build-ubuntu")
        if hostOS == OS_LINUX:
            makeForLaunchpad(doSign=doSign, isFirst=isFirstBuild, isSnapshot=isSnapshot)
        else:
            print("ERROR: Command 'build-ubuntu' can only be used on Linux")
            sys.exit(1)

    if "build-win-exe" in sys.argv:
        sys.argv.remove("build-win-exe")
        makeWindowsEmbedded(sys.argv)
        sys.exit(0)  # Don't continue execution

    if "build-appimage" in sys.argv:
        sys.argv.remove("build-appimage")
        if hostOS == OS_LINUX:
            sys.argv = makeAppImage(sys.argv)  # Build appimage and prune its args
        else:
            print("ERROR: Command 'build-appimage' can only be used on Linux")
            sys.exit(1)

    # General Installers
    # ==================

    if "xdg-install" in sys.argv:
        sys.argv.remove("xdg-install")
        if hostOS == OS_WIN:
            print("ERROR: Command 'xdg-install' cannot be used on Windows")
            sys.exit(1)
        else:
            xdgInstall()

    if "xdg-uninstall" in sys.argv:
        sys.argv.remove("xdg-uninstall")
        if hostOS == OS_WIN:
            print("ERROR: Command 'xdg-uninstall' cannot be used on Windows")
            sys.exit(1)
        else:
            xdgUninstall()

    if "win-install" in sys.argv:
        sys.argv.remove("win-install")
        if hostOS == OS_WIN:
            winInstall()
        else:
            print("ERROR: Command 'win-install' can only be used on Windows")
            sys.exit(1)

    if "win-uninstall" in sys.argv:
        sys.argv.remove("win-uninstall")
        if hostOS == OS_WIN:
            winUninstall()
        else:
            print("ERROR: Command 'win-uninstall' can only be used on Windows")
            sys.exit(1)

# END Main
