import datetime
import jstyleson
import os
import shutil
import sys
import pyperclip

from scripts import const, release_notes
from scripts import post_processing
from scripts import pack


def increment_version(version, release_type):
    if release_type == 0:
        return [version[0]+1, 0, 0]
    elif release_type == 1:
        return [version[0], version[1]+1, 0]
    elif release_type == 2:
        return [version[0], version[1], version[2]+1]
    elif release_type == 3:
        return [version[0], version[1], version[2]]


# omg the code above this comment sucks pls don't kill me

if __name__ == "__main__":
    from PyQt5 import QtGui, QtCore, QtWidgets, QtWebChannel, uic
    from PyQt5.QtCore import pyqtSignal, QUrl
    from PyQt5.QtWebEngineWidgets import QWebEngineView

    dir_file = os.path.dirname(__file__)
    dir_working = os.getcwd()

    class MDDocument(QtCore.QObject):
        textChanged = pyqtSignal(str)
        requestText = pyqtSignal()
        def __init__(self, parent=None):
            super().__init__(parent)
            self.text_content = ""
        def get_text(self):
            return self.text_content
        def set_text(self, text:str):
            self.text_content = text
            self.textChanged.emit(self.text_content)
        @QtCore.pyqtSlot()
        def request_text(self):
            self.requestText.emit()
        text = QtCore.pyqtProperty(str, fget=get_text, fset=set_text, notify=textChanged)

    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self, config:object):
            super(MainWindow, self).__init__()
            uic.loadUi(os.path.join(dir_file, "resources/interface.ui"), self)

            self.config = config
            self.setup_menu_bar()

            self.input_release_type.currentIndexChanged.connect(self.input_release_type_changed)
            self.btn_package.clicked.connect(self.run_packaging_process)

            self.text_edit_release_headline.textChanged.connect(self.refresh_release_notes)
            self.text_edit_whats_new.textChanged.connect(self.refresh_release_notes)
            self.text_edit_fixes.textChanged.connect(self.refresh_release_notes)
            self.text_edit_other_notes.textChanged.connect(self.refresh_release_notes)

            self.release_type = 0
            self.release_notes_md_document = MDDocument(self)
            self.channel = QtWebChannel.QWebChannel()
            self.channel.registerObject("document", self.release_notes_md_document)
            self.webview.page().setWebChannel(self.channel)
            self.webview.setUrl(QUrl.fromLocalFile(os.path.join(dir_file, "./resources/index.html")))
            self.release_notes_md_document.requestText.connect(self.refresh_release_notes)

            self.release_datetime = datetime.datetime.now()

            self.menu_bar_packs_load(0)
        
        def setup_menu_bar(self):
            self.packs_load_functions = []
            menu_bar = self.menuBar
            menu_bar_configuration = menu_bar.addMenu("Configuration")
            menu_bar_packs = menu_bar_configuration.addMenu("Packs")
            for i, pack in enumerate(self.config["packs"]):
                packaged_name = pack["packaged_name"]
                action = menu_bar_packs.addAction(packaged_name)
                f = lambda _,i=i: self.menu_bar_packs_load(index=i)
                action.triggered.connect(f)
        
        def menu_bar_packs_load(self, index):
            self.selected_pack_index = index
            selected_pack = self.config["packs"][index]
            path_bp = selected_pack["path_bp"]
            path_rp = selected_pack["path_rp"]
            pack_bp = pack.Pack(path_bp)
            pack_rp = pack.Pack(path_rp)
            self.set_packs(pack_bp, pack_rp)
            self.label_title.setText("MACHINE_BUILDER's Packaging Utility - "+selected_pack["packaged_name"])

        def set_packs(self, pack_bp:pack.Pack, pack_rp:pack.Pack):
            self.pack_bp = pack_bp
            self.pack_rp = pack_rp
            self.reload_packs()
        
        def reload_packs(self):
            self.pack_version = self.pack_bp.get_pack_version()
            self.pack_version_next = self.pack_version.copy()
            self.refresh_pack_version()

        def refresh_pack_version(self):
            self.pack_version_next = increment_version(self.pack_version, self.release_type)
            pack_version_str = ".".join([str(x) for x in self.pack_version])
            pack_version_next_str = ".".join([str(x) for x in self.pack_version_next])
            self.line_version.setText(pack_version_str)
            self.line_version_next.setText(pack_version_next_str)
            self.refresh_release_notes()
        
        def refresh_release_notes(self):
            selected_pack = self.config["packs"][self.selected_pack_index]
            self.release_notes_md_text = release_notes.generate_release_notes(
                config=self.config,
                selected_pack=selected_pack,
                release_type=self.release_type,
                version_str=self.line_version_next.text(),
                release_datetime=self.release_datetime,
                text_release_headline=self.text_edit_release_headline.toPlainText(),
                text_whats_new=self.text_edit_whats_new.toPlainText(),
                text_fixes=self.text_edit_fixes.toPlainText(),
                text_other_notes=self.text_edit_other_notes.toPlainText())
            self.release_notes_md_document.set_text(self.release_notes_md_text)
        
        def input_release_type_changed(self):
            self.release_type = self.input_release_type.currentIndex()
            self.refresh_pack_version()
        
        def run_packaging_process(self):
            selected_pack = self.config["packs"][self.selected_pack_index]

            if self.release_type != 4:
                self.pack_bp.set_pack_version(self.pack_version_next)
                self.pack_bp.save_changes()
                self.pack_rp.set_pack_version(self.pack_version_next)
                self.pack_rp.save_changes()
                
            release_info = {
                "release_type": const.RELEASE_TYPE[self.release_type],
                "version": self.pack_version_next,
                "packaged_on": [
                    int(self.release_datetime.timestamp()),
                    self.release_datetime.ctime()
                ],
                "release_notes": self.release_notes_md_text
            }

            print("Packing...")

            version_str = self.line_version_next.text()
            release_path_pack = os.path.join(self.config["releases_path"], selected_pack["packaged_name"])
            if not os.path.exists(self.config["releases_path"]):
                os.mkdir(self.config["releases_path"])
            if not os.path.exists(release_path_pack):
                os.mkdir(release_path_pack)
            release_path = os.path.join(release_path_pack, version_str)
            
            try:
                os.mkdir(release_path)
            except:
                QtWidgets.QMessageBox.critical(
                    self,
                    "Packaging Failure",
                    ("Could not create release folder. "
                        "This may be due to the release type "
                        "being set to repackage and the "
                        "version folder already in the "
                        "releases folder.")
                )
                return
            
            for output in selected_pack["outputs"]:
                pack.pack_bp_and_rp(
                    dir_file,
                    self.pack_bp, self.pack_rp,
                    os.path.join(release_path, selected_pack["packaged_name"]+output["packaged_suffix"]+"_"+version_str),
                    output.get("post_processing", {}))
                try:
                    jstyleson.dump(release_info, open(os.path.join(release_path, "info.json"), "w"), indent=4)
                except:
                    print("Failed to save release info.json")
            pyperclip.copy(self.release_notes_md_text)
            self.reload_packs()
            print("Complete")
            QtWidgets.QMessageBox.information(
                self,
                f"{version_str} Packaging Complete",
                ("The packaging process completed successfully. "
                    "To distribute this release, see the version "
                    "folder in the releases folder. Also note that "
                    "the changelog has been copied to clipboard, "
                    "and is also available in the release info.json file.")
            )

    os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
    os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
    QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)

    config_filepath = os.path.join(dir_file, "config.json")
    if os.path.exists(config_filepath):
        with open(config_filepath, encoding="utf-8") as config_file:
            config = jstyleson.load(config_file)
    else:
        # write config file and send a little message
        print("No config.json file found in project folder.")
        print("One has been created for you.")
        print("Please add your packs to config before re-running.")
        with open(config_filepath, "w") as config_file:
            jstyleson.dump(const.CONFIG_DEFAULT, config_file, indent=4, ensure_ascii=False)
        sys.exit(-1)
    
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow(config)
    window.show()
    app.exec_()
main.py
Machine-builder/MBsPackagingUtil
from . import const
from . import pack
from . import release_notes
from . import post_processing
scripts/__init__.py
Machine-builder/MBsPackagingUtil
RELEASE_TYPE = ["Major", "Minor", "Patch", "Repackage"]
CONFIG_DEFAULT = {
    "packs": [
        {
            "packaged_name": "OUTPUT_NAME_FOR_THIS_PACK",
            "path_bp": "FULL_PATH_TO_BEHAVIOR_PACK",
            "path_rp": "FULL_PATH_TO_RESOURCE_PACK",
            "outputs": [
                {
                    "packaged_suffix": "_Public",
                    "post_processing": {
                        "minify_js": "esbuild",
                        "obscure_json": True
                    }
                },
                {
                    "packaged_suffix": "_Private",
                    "post_processing": {}
                }
            ]
        }
    ],
    "releases_path": "FULL_PATH_TO_RELEASES_DIRECTORY",
    "notes": {
        "type_major": "🎉 Major Release 🎉",
        "type_minor": "✨ Minor Release ✨",
        "type_patch": "💻 Patch 💻",
        "type_repackage": "📁 Repackage 📁",
        "header_whats_new": "🚀 What's New",
        "header_bug_fixes": "🔧 Bug Fixes",
        "header_other_notes": "📚 Other Notes",
        "include_ctime": True
    }
}
scripts/const.py
Machine-builder/MBsPackagingUtil
import os, shutil
import jstyleson

from . import post_processing

class Pack():
    def __init__(self, path:str):
        self.path = path
        self.manifest_path = os.path.join(self.path, "manifest.json")
        self.reload()
    
    def reload(self):
        self.manifest = self.get_manifest()

    def save_changes(self):
        self.set_manifest(self.manifest)
    
    def get_manifest(self):
        return jstyleson.load(open(self.manifest_path, "r"))
    
    def set_manifest(self, data:object):
        return jstyleson.dump(data, open(self.manifest_path, "w"), indent=4)

    def get_pack_version(self) -> list:
        return self.manifest["header"]["version"]

    def set_pack_version(self, version:list):
        self.manifest["header"]["version"] = version
        for dependency in self.manifest.get("dependencies", []):
            uuid = dependency.get("uuid", None)
            if uuid is None: continue
            dependency["version"] = version
    
    def pack_to(self, filepath:str):
        """Packs this pack to a zip with the .mcpack
        extension, and saves to some filepath

        Args:
            filepath (str): Filepath to pack to. Omit the .mcpack extension.
        """
        shutil.make_archive(filepath, "zip", self.path)
        os.rename(filepath+".zip", filepath+".mcpack")

def pack_bp_and_rp(dir_file:str, bp:Pack, rp:Pack, filepath:str, post_processing_info:dict):
    tmp_folder = os.path.join(dir_file, "releases/tmp")
    if os.path.exists(tmp_folder):
        shutil.rmtree(tmp_folder)
    os.mkdir(tmp_folder)
    bp_path_tmp = os.path.join(dir_file, "releases/tmp/BP")
    rp_path_tmp = os.path.join(dir_file, "releases/tmp/RP")
    shutil.copytree(bp.path, bp_path_tmp)
    shutil.copytree(rp.path, rp_path_tmp)

    post_processing_minify = post_processing_info.get("minify_js", False)
    if post_processing_minify == "terser":
        post_processing.minify_js_terser(bp_path_tmp)
    elif post_processing_minify == "esbuild":
        post_processing.minify_js_esbuild(bp_path_tmp)
    post_processing_obscure = post_processing_info.get("obscure_json", False)
    if post_processing_obscure:
        post_processing.obscure_json(bp_path_tmp)
        post_processing.obscure_json(rp_path_tmp)

    shutil.make_archive(filepath, "zip", tmp_folder)
    os.rename(filepath+".zip", filepath+".mcaddon")
    shutil.rmtree(tmp_folder)
scripts/pack.py
Machine-builder/MBsPackagingUtil
import os
import subprocess
import shutil
import jstyleson

def minify_js_terser(folder_path:str):
    scripts_path = os.path.join(folder_path, "scripts")
    if not os.path.exists(scripts_path): return False
    for root, dirs, files in os.walk(scripts_path, topdown=False):
        for name in files:
            filepath = os.path.join(root, name)
            _, ext = os.path.splitext(filepath)
            if ext == ".js":
                command = f'terser --compress --mangle -- "{filepath}"'
                output = subprocess.getoutput(command)
                with open(filepath, "w") as f:
                    f.write(output)

def minify_js_esbuild(folder_path:str):
    scripts_path = os.path.join(folder_path, "scripts")
    main_js_path = os.path.join(scripts_path, "main.js")
    main_js_path_tmp = os.path.join(folder_path, "tmp_main.js")
    command = f'esbuild "{main_js_path}" --minify '\
        f'--bundle --target=es2020 --format=esm '\
        f'--outfile="{main_js_path_tmp}" '\
        f'--external:@minecraft/server '\
        f'--external:@minecraft/server-ui '\
        f'--external:@minecraft/server-admin '\
        f'--external:@minecraft/server-editor '\
        f'--external:@minecraft/server-net '\
        f'--external:@minecraft/server-gametest '\
        f'--external:@minecraft/vanilla-data'
    esbuild_output = subprocess.getoutput(command)
    if os.path.exists(main_js_path_tmp):
        shutil.rmtree(scripts_path)
        os.mkdir(scripts_path)
        with open(main_js_path, "wb") as f:
            with open(main_js_path_tmp, "rb") as f2:
                f.write(f2.read())
        os.remove(main_js_path_tmp)
    else:
        print("ERROR esbuild failed to process code. Command and output:")
        print(command)
        print(esbuild_output)

def recursive_json_encode(data):
    if type(data) == dict:
        newdata = {}
        for k,v in list(data.items()):
            k2 = recursive_json_encode(k)
            v2 = recursive_json_encode(v)
            newdata[k2] = v2
        return newdata
    elif type(data) == list:
        return [recursive_json_encode(i) for i in data]
    elif type(data) == str:
        return "".join([
            "\\u"+hex(ord(character))[2:].rjust(4, "0")
            for character in data])
    return data

def obscure_json(folder_path:str):
    skip_filenames = [
        "manifest.json"
    ]
    for root, _, files in os.walk(folder_path, topdown=False):
        for name in files:
            if name in skip_filenames:
                continue
            filepath = os.path.join(root, name)
            _, ext = os.path.splitext(filepath)
            if ext == ".json":
                try:
                    with open(filepath, "r") as f:
                        data = jstyleson.load(f)
                except:
                    print("Failed to read file", filepath)
                    continue
                encoded_data = recursive_json_encode(data)
                try:
                    with open(filepath, "wb") as f:
                        out = jstyleson.dumps(
                            encoded_data,
                            separators=(",", ":"),
                            ensure_ascii=False).encode().replace(b"\\\\", b"\\")
                        f.write(out)
                except:
                    print("Failed to write file", filepath)
                    continue
scripts/post_processing.py
Machine-builder/MBsPackagingUtil
import datetime

def generate_release_notes(
        config: dict,
        selected_pack: dict,
        release_type: int,
        version_str: str,
        release_datetime: datetime.datetime,
        text_release_headline: str,
        text_whats_new: str,
        text_fixes: str,
        text_other_notes: str):

    if release_type == 0:
        release_type_text = config["notes"]["type_major"]
    elif release_type == 1:
        release_type_text = config["notes"]["type_minor"]
    elif release_type == 2:
        release_type_text = config["notes"]["type_patch"]
    elif release_type == 3:
        release_type_text = config["notes"]["type_repackage"]
    
    headline_text = ""
    whats_new_text = ""
    fixes_text = ""
    other_notes_text = ""

    if text_release_headline:
        headline_text = text_release_headline+"\n\n"
    if text_whats_new:
        whats_new_text = "### "+config["notes"]["header_whats_new"]+"\n"+text_whats_new+"\n\n"
    if text_fixes:
        fixes_text = "### "+config["notes"]["header_bug_fixes"]+"\n"+text_fixes+"\n\n"
    if text_other_notes:
        other_notes_text = "### "+config["notes"]["header_other_notes"]+"\n"+text_other_notes+"\n\n"
    
    result = (
        f"# {selected_pack['packaged_name']} Version {version_str}\n"
        f"**{release_type_text}**\n\n"
        f"{headline_text}"
        f"{whats_new_text}"
        f"{fixes_text}"
        f"{other_notes_text}")

    if config["notes"]["include_ctime"]:
        result += release_datetime.ctime()
    
    return result
scripts/release_notes.py
Machine-builder/MBsPackagingUtil