/*

AE Assets Sync Panel
Version: 1.0.2

What it does:
1) Sync Assets
   - Looks for the "Assets" folder next to the current .aep
   - Recreates the same folder structure in the Project panel
   - Imports only new files
   - Places already imported items into the same folders as in Finder
   - Removes only empty extra folders inside the assets root

2) Remove Unused Duplicates
   - Finds duplicates by the key: fileName + fileSize
   - Checks which FootageItems are actually used in compositions
   - Removes only unused duplicates
   - If no item in a duplicate group is used, keeps 1 item and removes the rest

Important:
- It is best to back up the project before first use
- Duplicates are identified by file name + file size
  This is safer than using the name alone, but it is still not a 100% hash check
- The script does not touch comp items, solids, folders, or placeholders
- The script can relink moved files inside ./Assets and preserve existing comp references

Installation:
- Save as: AssetsSyncPanel.jsx
- Put it in:
  Adobe After Effects / Support Files / Scripts / ScriptUI Panels
- Restart AE
- Open: Window > Assets Sync Panel

*/

(function AssetsSyncPanel(thisObj) {

    var SCRIPT_NAME = "Assets Sync Panel";
    var VERSION = "1.0.2";
    var ROOT_DISK_FOLDER_NAME = "Assets";
    var ROOT_PROJECT_FOLDER_NAME = "0. Assets";

    var reportLines = [];

    function loadFastPixelCloudKit() {
        var candidates = [];
        var scriptFile;
        var i;
        var file;

        try {
            candidates.push(Folder.myDocuments.fsName + "/Adobe/After Effects 2026/Scripts/ScriptUI Panels/FastPixelTools/FastPixelCloudKit.jsxinc");
        } catch (documentsError) {
        }

        try {
            scriptFile = new File($.fileName);
            candidates.push(scriptFile.parent.fsName + "/FastPixelTools/FastPixelCloudKit.jsxinc");
        } catch (scriptPathError) {
        }

        for (i = 0; i < candidates.length; i++) {
            file = new File(candidates[i]);
            if (!file.exists) continue;
            try {
                $.evalFile(file);
                if ($.global.FastPixelCloudKit) {
                    return $.global.FastPixelCloudKit;
                }
            } catch (kitError) {
            }
        }

        return null;
    }

    function createFastPixelCloud(mainPanel) {
        var kit = loadFastPixelCloudKit();
        if (!kit || !kit.create) {
            return null;
        }

        return kit.create({
            productId: "assets-sync-panel",
            productName: SCRIPT_NAME,
            version: VERSION,
            scriptPath: $.fileName,
            settingsSection: "AssetsSyncPanel",
            mainPanel: mainPanel
        });
    }

    function clearScriptUiChildren(container) {
        if (!container || !container.children) return;
        for (var i = container.children.length - 1; i >= 0; i--) {
            try {
                container.remove(container.children[i]);
            } catch (removeError) {
            }
        }
    }

    // =========================================================
    // Helpers
    // =========================================================

    function logLine(msg) {
        reportLines.push(msg);
    }

    function resetReport() {
        reportLines = [];
    }

    function getReportText() {
        return reportLines.join("\n");
    }

    function normalizePath(pathStr) {
        return String(pathStr).toLowerCase().replace(/\\/g, "/");
    }

    function safeFsName(fileOrFolder) {
        try {
            return fileOrFolder.fsName;
        } catch (e) {
            return "";
        }
    }

    function decodeProjectItemName(name) {
        var value = String(name || "");

        if (!/%[0-9A-Fa-f]{2}/.test(value)) {
            return value;
        }

        try {
            return decodeURIComponent(value);
        } catch (e) {
            return value.replace(/%20/g, " ");
        }
    }

    function normalizeProjectItemName(name) {
        return decodeProjectItemName(name).toLowerCase();
    }

    function isHiddenEntry(fileOrFolder) {
        if (!fileOrFolder || !fileOrFolder.name) return false;
        return fileOrFolder.name.charAt(0) === ".";
    }

    function fileExists(fileObj) {
        try {
            return fileObj && fileObj.exists;
        } catch (e) {
            return false;
        }
    }

    function getProjectFile() {
        try {
            return app.project.file;
        } catch (e) {
            return null;
        }
    }

    function getProjectDir() {
        var pf = getProjectFile();
        if (!pf) return null;
        return pf.parent;
    }

    function getAssetsDiskRoot() {
        var dir = getProjectDir();
        if (!dir) return null;
        return new Folder(dir.fsName + "/" + ROOT_DISK_FOLDER_NAME);
    }

    function isPathInsideFolder(pathStr, folderObj) {
        if (!pathStr || !folderObj) return false;

        var normalizedPath = normalizePath(pathStr);
        var normalizedFolder = normalizePath(folderObj.fsName);

        return normalizedPath === normalizedFolder ||
            normalizedPath.indexOf(normalizedFolder + "/") === 0;
    }

    function ensureProjectSaved() {
        if (!app.project || !app.project.file) {
            alert("Save the .aep project first.");
            return false;
        }
        return true;
    }

    function isImportableFile(fileObj) {
        if (!(fileObj instanceof File)) return false;
        if (isHiddenEntry(fileObj)) return false;

        var match = fileObj.name.match(/\.([^.]+)$/);
        if (!match) return false;

        var ext = match[1].toLowerCase();

        var allowed = {
            // images
            "png": true,
            "jpg": true,
            "jpeg": true,
            "psd": true,
            "ai": true,
            "svg": true,
            "pdf": true,
            "tif": true,
            "tiff": true,
            "exr": true,

            // video
            "mov": true,
            "mp4": true,
            "m4v": true,
            "avi": true,
            "mxf": true,
            "mpeg": true,
            "mpg": true,
            "webm": true,

            // audio
            "wav": true,
            "aif": true,
            "aiff": true,
            "mp3": true,
            "m4a": true
        };

        return !!allowed[ext];
    }

    function collectDiskDuplicateNames(rootFolder) {
        var seen = {};
        var duplicates = {};

        function walk(folderObj) {
            var entries = folderObj.getFiles();
            var i, entry, key;

            for (i = 0; i < entries.length; i++) {
                entry = entries[i];

                if (entry instanceof Folder) {
                    if (!isHiddenEntry(entry)) walk(entry);
                    continue;
                }

                if (!(entry instanceof File) || !isImportableFile(entry)) continue;

                key = String(entry.name).toLowerCase();

                if (!seen[key]) {
                    seen[key] = [entry.fsName];
                    continue;
                }

                seen[key].push(entry.fsName);
                duplicates[key] = seen[key];
            }
        }

        walk(rootFolder);
        return duplicates;
    }

    function buildDiskDuplicateErrorText(duplicates) {
        var lines = ["Duplicate asset names found in ./Assets.", "", "Each asset file name must be unique across the whole Assets folder.", "Sync was cancelled to avoid ambiguous relinking.", ""];
        var key, paths, i;

        for (key in duplicates) {
            if (!duplicates.hasOwnProperty(key)) continue;

            lines.push(key + ":");
            paths = duplicates[key];

            for (i = 0; i < paths.length; i++) {
                lines.push("  - " + paths[i]);
            }

            lines.push("");
        }

        return lines.join("\n");
    }

    function getOrCreateProjectFolder(folderName, parentFolder) {
        folderName = decodeProjectItemName(folderName);
        var normalizedFolderName = normalizeProjectItemName(folderName);

        var i, item, itemNameDecoded, itemNameNormalized;
        var matches = [];
        var bestFolder = null;
        var bestScore = -1;
        var score;

        for (i = 1; i <= app.project.numItems; i++) {
            item = app.project.item(i);
            itemNameDecoded = decodeProjectItemName(item.name);
            itemNameNormalized = normalizeProjectItemName(item.name);

            if (item instanceof FolderItem &&
                itemNameNormalized === normalizedFolderName &&
                item.parentFolder === parentFolder) {
                matches.push(item);
            }
        }

        if (matches.length > 0) {
            for (i = 0; i < matches.length; i++) {
                item = matches[i];

                // Prefer folders that already contain children.
                // On ties, prefer the folder that already has the canonical readable name.
                score = item.numItems * 10;
                if (item.name === folderName) score += 1;

                if (score > bestScore) {
                    bestScore = score;
                    bestFolder = item;
                }
            }

            if (bestFolder.name !== folderName) {
                try {
                    bestFolder.name = folderName;
                } catch (e1) {}
            }

            for (i = 0; i < matches.length; i++) {
                item = matches[i];
                if (item === bestFolder) continue;

                if (item.numItems === 0) {
                    try {
                        item.remove();
                    } catch (e2) {}
                }
            }

            return bestFolder;
        }

        var newFolder = app.project.items.addFolder(folderName);
        newFolder.parentFolder = parentFolder;
        return newFolder;
    }

    function addItemToGroupMap(map, key, item) {
        if (!key) return;

        if (!map[key]) {
            map[key] = [];
        }

        map[key].push(item);
    }

    function pickCanonicalFootageItem(items, usedIds) {
        var i, item, bestItem, bestScore, score, itemExists;

        if (!items || items.length === 0) return null;

        bestItem = null;
        bestScore = -1;

        for (i = 0; i < items.length; i++) {
            item = items[i];
            if (!item) continue;

            score = 0;

            if (usedIds && usedIds[item.id]) score += 1000;

            itemExists = false;
            try {
                itemExists = fileExists(item.file);
            } catch (e) {}
            if (itemExists) score += 100;

            if (!bestItem ||
                score > bestScore ||
                (score === bestScore && item.id < bestItem.id)) {
                bestItem = item;
                bestScore = score;
            }
        }

        return bestItem;
    }

    function buildCanonicalItemMap(groupMap, usedIds) {
        var canonicalMap = {};
        var key;

        for (key in groupMap) {
            if (!groupMap.hasOwnProperty(key)) continue;
            canonicalMap[key] = pickCanonicalFootageItem(groupMap[key], usedIds);
        }

        return canonicalMap;
    }

    function getAssetIdentityName(name) {
        return decodeProjectItemName(name).toLowerCase();
    }

    function buildImportedPathMap(diskAssetsRoot, usedIds) {
        var pathGroupMap = {};
        var relocationGroupMap = {};
        var nameGroupMap = {};
        var i, item, p, relocationKey, filePath, identityName;

        for (i = 1; i <= app.project.numItems; i++) {
            item = app.project.item(i);

            if (item instanceof FootageItem && item.file) {
                try {
                    filePath = safeFsName(item.file);
                    p = normalizePath(filePath);
                    addItemToGroupMap(pathGroupMap, p, item);

                    if (isPathInsideFolder(filePath, diskAssetsRoot)) {
                        relocationKey = getFileRelocationKey(item.file);
                        if (relocationKey) {
                            addItemToGroupMap(relocationGroupMap, relocationKey, item);
                        }

                        identityName = getAssetIdentityName(item.name);
                        addItemToGroupMap(nameGroupMap, identityName, item);
                    }
                } catch (e) {}
            }
        }

        return {
            byPath: buildCanonicalItemMap(pathGroupMap, usedIds),
            byRelocationKey: buildCanonicalItemMap(relocationGroupMap, usedIds),
            byIdentityName: buildCanonicalItemMap(nameGroupMap, usedIds)
        };
    }

    function buildFolderPathMap(rootFolderItem) {
        var map = {};

        function walk(folderItem, currentPath) {
            var j, child, childPath;

            map[currentPath] = folderItem;

            for (j = 1; j <= folderItem.numItems; j++) {
                child = folderItem.item(j);
                if (child instanceof FolderItem) {
                    childPath = currentPath + "/" + child.name;
                    walk(child, childPath);
                }
            }
        }

        walk(rootFolderItem, rootFolderItem.name);
        return map;
    }

    function getFileRelocationKey(fileObj) {
        try {
            if (!(fileObj instanceof File)) return null;

            var name = String(fileObj.name).toLowerCase();
            var size = getFileSizeSafe(fileObj);

            if (size < 0) return null;

            return name + "||" + size;
        } catch (e) {
            return null;
        }
    }

    function importFileToFolder(fileObj, projectFolder, importedState, stats) {
        var path = normalizePath(fileObj.fsName);
        var existingItem = importedState.byPath[path];
        var decodedName = decodeProjectItemName(fileObj.name);
        var relocationKey, relocationCandidates, relocationItem, oldPath, identityName;

        if (existingItem) {
            if (existingItem.name !== decodedName) {
                try {
                    existingItem.name = decodedName;
                } catch (e1) {}
            }

            if (existingItem.parentFolder !== projectFolder) {
                try {
                    existingItem.parentFolder = projectFolder;
                    stats.movedExisting++;
                    logLine("Moved: " + fileObj.fsName + " -> " + projectFolder.name);
                } catch (e2) {
                    stats.errors++;
                    logLine("ERROR moving: " + fileObj.fsName + " | " + e2.toString());
                }
            } else {
                stats.skippedExistingPath++;
            }
            return;
        }

        relocationKey = getFileRelocationKey(fileObj);
        relocationCandidates = relocationKey ? importedState.byRelocationKey[relocationKey] : null;
        relocationItem = relocationCandidates || null;

        if (!relocationItem) {
            identityName = getAssetIdentityName(fileObj.name);
            relocationItem = importedState.byIdentityName[identityName];
        }

        if (relocationItem) {
            try {
                oldPath = normalizePath(relocationItem.file.fsName);
                relocationItem.replace(fileObj);
                relocationItem.name = decodedName;
                relocationItem.parentFolder = projectFolder;

                delete importedState.byPath[oldPath];
                importedState.byPath[path] = relocationItem;
                importedState.byIdentityName[getAssetIdentityName(relocationItem.name)] = relocationItem;
                stats.relinkedMovedFiles++;
                logLine("Relinked moved file: " + fileObj.fsName);
                return;
            } catch (e4) {
                stats.errors++;
                logLine("ERROR relinking moved file: " + fileObj.fsName + " | " + e4.toString());
            }
        }

        try {
            var io = new ImportOptions(fileObj);
            var imported = app.project.importFile(io);
            imported.name = decodedName;
            imported.parentFolder = projectFolder;
            importedState.byPath[path] = imported;

            relocationKey = getFileRelocationKey(fileObj);
            if (relocationKey) {
                if (!importedState.byRelocationKey[relocationKey]) importedState.byRelocationKey[relocationKey] = [];
                importedState.byRelocationKey[relocationKey].push(imported);
            }

            importedState.byIdentityName[getAssetIdentityName(imported.name)] = imported;

            stats.imported++;
            logLine("Imported: " + fileObj.fsName);
        } catch (e) {
            stats.errors++;
            logLine("ERROR importing: " + fileObj.fsName + " | " + e.toString());
        }
    }

    function syncDiskFolderRecursive(diskFolder, projectFolder, currentRelativePath, desiredFolderPaths, importedState, stats) {
        var entries, i, entry, childProjectFolder, childFolderName, childRelativePath;

        entries = diskFolder.getFiles();

        // Folders first
        for (i = 0; i < entries.length; i++) {
            entry = entries[i];

            if (entry instanceof Folder) {
                if (isHiddenEntry(entry)) continue;

                childFolderName = decodeProjectItemName(entry.name);
                childRelativePath = currentRelativePath
                    ? currentRelativePath + "/" + childFolderName
                    : childFolderName;

                desiredFolderPaths[normalizePath(childRelativePath)] = true;

                childProjectFolder = getOrCreateProjectFolder(childFolderName, projectFolder);
                stats.foldersEnsured++;
                syncDiskFolderRecursive(entry, childProjectFolder, childRelativePath, desiredFolderPaths, importedState, stats);
            }
        }

        // Files second
        for (i = 0; i < entries.length; i++) {
            entry = entries[i];

            if (entry instanceof File && isImportableFile(entry)) {
                importFileToFolder(entry, projectFolder, importedState, stats);
            }
        }
    }

    function removeEmptyExtraProjectFolders(folderItem, currentRelativePath, desiredFolderPaths, stats) {
        var i, child, childRelativePath, childName;

        for (i = folderItem.numItems; i >= 1; i--) {
            child = folderItem.item(i);
            if (!(child instanceof FolderItem)) continue;

            childName = decodeProjectItemName(child.name);
            childRelativePath = currentRelativePath
                ? currentRelativePath + "/" + childName
                : childName;

            removeEmptyExtraProjectFolders(child, childRelativePath, desiredFolderPaths, stats);

            if (child.numItems === 0 && !desiredFolderPaths[normalizePath(childRelativePath)]) {
                try {
                    logLine("Removed empty extra folder: " + childRelativePath);
                    child.remove();
                    stats.emptyFoldersRemoved++;
                } catch (e) {
                    stats.errors++;
                    logLine("ERROR removing empty folder: " + childRelativePath + " | " + e.toString());
                }
            }
        }
    }

    function scanUsedFootageIds() {
        var used = {};
        var i, j, comp, layer, src;

        for (i = 1; i <= app.project.numItems; i++) {
            comp = app.project.item(i);

            if (!(comp instanceof CompItem)) continue;

            for (j = 1; j <= comp.numLayers; j++) {
                layer = comp.layer(j);

                try {
                    src = layer.source;
                    if (src && src instanceof FootageItem) {
                        used[src.id] = true;
                    }
                } catch (e) {}
            }
        }

        return used;
    }

    function getFileSizeSafe(fileObj) {
        try {
            return fileObj.length;
        } catch (e) {
            return -1;
        }
    }

    function getDuplicateKey(item) {
        // Safe practical key:
        // lowercased file name + size
        // path is intentionally not used here, otherwise duplicates from different locations would not be found
        try {
            if (!(item instanceof FootageItem) || !item.file) return null;

            var fileObj = item.file;
            var name = String(fileObj.name).toLowerCase();
            var size = getFileSizeSafe(fileObj);

            if (size < 0) return null;

            return name + "||" + size;
        } catch (e) {
            return null;
        }
    }

    function collectFootageGroupsByDuplicateKey() {
        var groups = {};
        var i, item, key;

        for (i = 1; i <= app.project.numItems; i++) {
            item = app.project.item(i);

            if (!(item instanceof FootageItem)) continue;
            if (!item.file) continue; // skip solids/placeholders etc. that have no file

            key = getDuplicateKey(item);
            if (!key) continue;

            if (!groups[key]) groups[key] = [];
            groups[key].push(item);
        }

        return groups;
    }

    function sortItemsStableById(items) {
        items.sort(function(a, b) {
            return a.id - b.id;
        });
    }

    function removeUnusedDuplicates() {
        if (!ensureProjectSaved()) return;

        app.beginUndoGroup("Remove Unused Duplicates");

        resetReport();

        var usedIds = scanUsedFootageIds();
        var groups = collectFootageGroupsByDuplicateKey();

        var totalGroups = 0;
        var duplicateGroups = 0;
        var removedCount = 0;
        var keptCount = 0;
        var removedNames = [];
        var key, items, i, usedItems, unusedItems, keepItem;

        for (key in groups) {
            if (!groups.hasOwnProperty(key)) continue;

            totalGroups++;
            items = groups[key];

            if (!items || items.length < 2) continue;

            duplicateGroups++;

            sortItemsStableById(items);

            usedItems = [];
            unusedItems = [];

            for (i = 0; i < items.length; i++) {
                if (usedIds[items[i].id]) usedItems.push(items[i]);
                else unusedItems.push(items[i]);
            }

            if (usedItems.length > 0) {
                // There are items actually used in comps
                // Remove only unused duplicates
                for (i = 0; i < unusedItems.length; i++) {
                    try {
                        logLine("Removed unused duplicate: " + unusedItems[i].name);
                        removedNames.push(unusedItems[i].name);
                        unusedItems[i].remove();
                        removedCount++;
                    } catch (e1) {
                        logLine("ERROR removing duplicate: " + unusedItems[i].name + " | " + e1.toString());
                    }
                }
                keptCount += usedItems.length;
            } else {
                // No item is used in compositions
                // Keep one, remove the rest
                keepItem = items[0];
                keptCount++;

                for (i = 1; i < items.length; i++) {
                    try {
                        logLine("Removed unused duplicate: " + items[i].name);
                        removedNames.push(items[i].name);
                        items[i].remove();
                        removedCount++;
                    } catch (e2) {
                        logLine("ERROR removing duplicate: " + items[i].name + " | " + e2.toString());
                    }
                }
            }
        }

        logLine("");
        logLine("Duplicate groups found: " + duplicateGroups);
        logLine("Items removed: " + removedCount);
        logLine("Items kept: " + keptCount);

        app.endUndoGroup();

        alert(
            "Remove Unused Duplicates completed.\n\n" +
            "Duplicate groups: " + duplicateGroups + "\n" +
            "Removed: " + removedCount + "\n" +
            "Kept: " + keptCount
        );
    }

    function syncAssets() {
        if (!ensureProjectSaved()) return;

        var diskAssetsRoot = getAssetsDiskRoot();
        var duplicateDiskNames;

        if (!diskAssetsRoot || !diskAssetsRoot.exists) {
            alert("Assets folder not found next to the project:\n\n" +
                  (diskAssetsRoot ? diskAssetsRoot.fsName : "(unknown path)"));
            return;
        }

        duplicateDiskNames = collectDiskDuplicateNames(diskAssetsRoot);
        if (buildObjectKeyCount(duplicateDiskNames) > 0) {
            alert(buildDiskDuplicateErrorText(duplicateDiskNames));
            return;
        }

        app.beginUndoGroup("Sync Assets");

        resetReport();

        var stats = {
            foldersEnsured: 0,
            imported: 0,
            movedExisting: 0,
            relinkedMovedFiles: 0,
            skippedExistingPath: 0,
            emptyFoldersRemoved: 0,
            errors: 0
        };

        var usedIds = scanUsedFootageIds();
        var importedState = buildImportedPathMap(diskAssetsRoot, usedIds);
        var desiredFolderPaths = {};

        var projectAssetsRoot = getOrCreateProjectFolder(ROOT_PROJECT_FOLDER_NAME, app.project.rootFolder);
        stats.foldersEnsured++;

        syncDiskFolderRecursive(diskAssetsRoot, projectAssetsRoot, "", desiredFolderPaths, importedState, stats);
        removeEmptyExtraProjectFolders(projectAssetsRoot, "", desiredFolderPaths, stats);

        logLine("");
        logLine("Folders ensured: " + stats.foldersEnsured);
        logLine("Imported: " + stats.imported);
        logLine("Moved existing: " + stats.movedExisting);
        logLine("Relinked moved files: " + stats.relinkedMovedFiles);
        logLine("Already in correct folder: " + stats.skippedExistingPath);
        logLine("Empty folders removed: " + stats.emptyFoldersRemoved);
        logLine("Errors: " + stats.errors);

        app.endUndoGroup();

        alert(
            "Sync Assets completed.\n\n" +
            "Folders ensured: " + stats.foldersEnsured + "\n" +
            "Imported: " + stats.imported + "\n" +
            "Moved existing: " + stats.movedExisting + "\n" +
            "Relinked moved files: " + stats.relinkedMovedFiles + "\n" +
            "Already correct: " + stats.skippedExistingPath + "\n" +
            "Empty folders removed: " + stats.emptyFoldersRemoved + "\n" +
            "Errors: " + stats.errors
        );
    }

    function buildObjectKeyCount(obj) {
        var count = 0;
        var key;

        for (key in obj) {
            if (obj.hasOwnProperty(key)) count++;
        }

        return count;
    }

    function showReport() {
        var txt = getReportText();
        if (!txt || txt === "") txt = "Report is empty.";
        alert(txt);
    }

    function showAbout() {
        alert(
            "Assets Sync Panel\n\n" +
            "What it does:\n" +
            "- Looks for the ./Assets folder next to the current .aep\n" +
            "- Uses 0. Assets as the root folder in the AE Project panel\n" +
            "- Mirrors the Finder folder structure inside AE\n" +
            "- Imports only missing files\n" +
            "- Moves existing imported items into the correct folders\n" +
            "- Relinks moved files instead of creating duplicates when possible\n" +
            "- Removes only empty extra folders inside 0. Assets\n\n" +
            "Important:\n" +
            "- The disk folder must stay named Assets\n" +
            "- Asset file names inside ./Assets must be unique across the whole folder tree\n" +
            "- Foreign items inside 0. Assets are preserved unless they are inside an empty folder"
        );
    }

    // =========================================================
    // UI
    // =========================================================

    function buildUI(thisObj) {
        var isDockedPanel = (thisObj instanceof Panel);
        var pal = isDockedPanel
            ? thisObj
            : new Window("palette", SCRIPT_NAME, undefined, { resizeable: true });

        if (!pal) return pal;
        if (isDockedPanel) {
            clearScriptUiChildren(pal);
        }

        pal.orientation = "column";
        pal.alignChildren = ["fill", "top"];
        pal.spacing = 10;
        pal.margins = 12;

        var cloud = createFastPixelCloud(pal);

        var header = pal.add("group");
        header.orientation = "row";
        header.alignChildren = ["fill", "center"];
        header.alignment = ["fill", "top"];

        var headerSpacer = header.add("group");
        headerSpacer.alignment = ["fill", "fill"];

        var headerActions = header.add("group");
        headerActions.orientation = "row";
        headerActions.alignChildren = ["right", "center"];
        headerActions.alignment = ["right", "center"];
        if (cloud) {
            cloud.bindHeaderActions(headerActions);
        }

        var actionsRow = pal.add("group");
        actionsRow.orientation = "column";
        actionsRow.alignChildren = ["fill", "top"];
        actionsRow.alignment = ["fill", "top"];
        actionsRow.spacing = 8;

        var btnSync = actionsRow.add("button", undefined, "Sync Assets");
        btnSync.alignment = ["fill", "top"];

        var ddMore = actionsRow.add("dropdownlist", undefined, [
            "More",
            "About",
            "Report",
            "Remove Unused Duplicates"
        ]);
        ddMore.selection = 0;

        btnSync.onClick = function () {
            syncAssets();
        };

        ddMore.onChange = function () {
            if (!ddMore.selection) return;

            if (ddMore.selection.index === 1) {
                showAbout();
            } else if (ddMore.selection.index === 2) {
                showReport();
            } else if (ddMore.selection.index === 3) {
                var ok = confirm(
                    "Remove unused duplicate assets?\n\n" +
                    "Logic:\n" +
                    "- if a duplicate is used in a composition, it will be kept\n" +
                    "- if a duplicate group contains unused items, those items will be removed\n" +
                    "- if the whole group is unused, one item will be kept and the rest will be removed\n\n" +
                    "Saving the project before running is recommended."
                );

                if (ok) {
                    removeUnusedDuplicates();
                }
            }

            ddMore.selection = 0;
        };

        pal.layout.layout(true);
        pal.layout.resize();
        pal.onResizing = pal.onResize = function () {
            this.layout.resize();
        };

        return pal;
    }

    var reloadTarget = null;
    try {
        reloadTarget = $.global.__FAST_PIXEL_RELOAD_TARGET__ || null;
    } catch (reloadTargetError) {
        reloadTarget = null;
    }

    var myPal = buildUI(reloadTarget || thisObj);
    try {
        $.global.__FAST_PIXEL_RELOAD_TARGET__ = null;
    } catch (clearReloadTargetError) {
    }

    if (myPal instanceof Window) {
        myPal.center();
        myPal.show();
    }

})(this);
