ChatGPT Sidebar GPT Reorder

Reorder GPTs in ChatGPT sidebar with a custom sort list.

Від 17.09.2024. Дивіться остання версія.

// ==UserScript==
// @name         ChatGPT Sidebar GPT Reorder
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Reorder GPTs in ChatGPT sidebar with a custom sort list.
// @author       @MartianInGreen
// @match        https://*.chatgpt.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CUSTOM_SORT_KEY = 'customGPTSort';
    const REORDER_BUTTON_ID = 'reorder-gpts-button';
    const MODAL_ID = 'gpt-sort-modal-overlay';

    // Utility function to wait for an element based on a predicate function
    function waitForElement(predicate, timeout = 20000) {
        return new Promise((resolve, reject) => {
            if (predicate()) {
                return resolve(predicate());
            }

            const observer = new MutationObserver(() => {
                if (predicate()) {
                    resolve(predicate());
                    observer.disconnect();
                }
            });

            observer.observe(document.body, { childList: true, subtree: true });

            setTimeout(() => {
                observer.disconnect();
                reject(new Error('Element not found within timeout.'));
            }, timeout);
        });
    }

    // Function to identify the target button (adjust if necessary)
    function identifyTargetButton() {
        // Example: Find a button with specific aria-label or class
        const buttons = Array.from(document.querySelectorAll('button'));
        for (let btn of buttons) {
            if (btn.textContent.toLowerCase().includes('more')) {
                return btn;
            }
            // Add other identification logic if needed
        }

        // Fallback
        return null;
    }

    // Function to get all GPT elements (Update selector based on actual DOM)
    function getAllGPTs() {
        // Updated selector based on provided HTML
        const gpts = Array.from(document.querySelectorAll('div[tabindex="0"] > a.group.flex.h-10'));
        console.log('Found GPTs:', gpts);
        return gpts;
    }

    // Function to get the GPT container (Update selector based on actual DOM)
    function getGPTContainer() {
        const gpts = getAllGPTs();
        if (gpts.length === 0) return null;
        const container = gpts[0].parentElement;
        console.log('GPT Container:', container);
        return container;
    }

    // Function to save custom sort to localStorage
    function saveCustomSort(sortList) {
        localStorage.setItem(CUSTOM_SORT_KEY, JSON.stringify(sortList));
    }

    // Function to load custom sort from localStorage
    function loadCustomSort() {
        const data = localStorage.getItem(CUSTOM_SORT_KEY);
        return data ? JSON.parse(data) : null;
    }

    // Function to initialize custom sort
    function initializeCustomSort() {
        const gpts = getAllGPTs();
        const sortList = gpts.map(gpt => {
            const nameElement = gpt.querySelector('div.text-sm.text-token-text-primary'); // Update selector if necessary
            return {
                name: nameElement ? nameElement.textContent.trim() : '',
                url: gpt.getAttribute('href'),
                icon: gpt.querySelector('img') ? gpt.querySelector('img').src : ''
            };
        });
        saveCustomSort(sortList);
        return sortList;
    }

    // Function to reorder GPTs based on sort list
    function reorderGPTs(sortList) {
        const gptContainer = getGPTContainer();
        if (!gptContainer) {
            console.warn('GPT container not found. Cannot reorder GPTs.');
            return;
        }

        const gpts = getAllGPTs();
        const gptMap = {};
        gpts.forEach(gpt => {
            const nameElement = gpt.querySelector('div.text-sm.text-token-text-primary'); // Update selector if necessary
            const name = nameElement ? nameElement.textContent.trim() : '';
            if (name) {
                gptMap[name] = gpt;
            }
        });

        // Clear current GPTs
        gpts.forEach(gpt => {
            if (gptContainer.contains(gpt)) {
                gptContainer.removeChild(gpt);
            } else {
                console.warn('GPT is not a child of the container:', gpt);
            }
        });

        // Append GPTs based on sortList
        sortList.forEach(item => {
            if (gptMap[item.name]) {
                gptContainer.appendChild(gptMap[item.name]);
                delete gptMap[item.name];
            }
        });

        // Append any remaining GPTs (new ones) to the end
        Object.values(gptMap).forEach(gpt => {
            gptContainer.appendChild(gpt);
        });
    }

    // Function to create the Sort UI Modal
    function createSortModal(sortList) {
        // If modal already exists, remove it
        const existingModal = document.getElementById(MODAL_ID);
        if (existingModal) {
            existingModal.remove();
        }

        // Create modal overlay
        const modalOverlay = document.createElement('div');
        modalOverlay.id = MODAL_ID;
        Object.assign(modalOverlay.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            backgroundColor: 'rgba(0,0,0,0.5)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            zIndex: '10000'
        });

        // Create modal content
        const modalContent = document.createElement('div');
        Object.assign(modalContent.style, {
            backgroundColor: '#fff',
            padding: '20px',
            borderRadius: '8px',
            width: '400px',
            maxHeight: '80%',
            overflowY: 'auto',
            boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
            color: '#000' // Set text color to black
        });

        // Modal header
        const header = document.createElement('h2');
        header.textContent = 'Reorder GPTs';
        Object.assign(header.style, {
            marginTop: '0',
            marginBottom: '10px',
            textAlign: 'center',
            color: '#000' // Ensure header text is black
        });
        modalContent.appendChild(header);

        // Instructions
        const instructions = document.createElement('p');
        instructions.textContent = 'Drag and drop the GPTs to reorder them. Click "Save" to apply changes or "Refresh" to revert.';
        Object.assign(instructions.style, {
            fontSize: '14px',
            marginBottom: '10px',
            textAlign: 'center',
            color: '#000' // Set text color to black
        });
        modalContent.appendChild(instructions);

        // Create list container
        const list = document.createElement('ul');
        list.id = 'gpt-sort-list';
        Object.assign(list.style, {
            listStyleType: 'none',
            padding: '0',
            marginBottom: '20px'
        });

        sortList.forEach(item => {
            const listItem = document.createElement('li');
            listItem.textContent = item.name;
            listItem.setAttribute('data-name', item.name);
            Object.assign(listItem.style, {
                padding: '8px',
                margin: '4px 0',
                backgroundColor: '#f0f0f0',
                borderRadius: '4px',
                cursor: 'grab',
                color: '#000' // Set list item text color to black
            });
            list.appendChild(listItem);
        });

        modalContent.appendChild(list);

        // Buttons container
        const buttonsContainer = document.createElement('div');
        Object.assign(buttonsContainer.style, {
            display: 'flex',
            justifyContent: 'space-between'
        });

        // Export button
        const exportButton = document.createElement('button');
        exportButton.textContent = 'Export';
        Object.assign(exportButton.style, {
            padding: '8px 16px',
            backgroundColor: '#555555',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            marginRight: 'auto'
        });
        buttonsContainer.appendChild(exportButton);

        // Import button
        const importButton = document.createElement('button');
        importButton.textContent = 'Import';
        Object.assign(importButton.style, {
            padding: '8px 16px',
            backgroundColor: '#555555',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            marginLeft: '10px'
        });
        buttonsContainer.appendChild(importButton);

        // Refresh button
        const refreshButton = document.createElement('button');
        refreshButton.textContent = 'Refresh';
        Object.assign(refreshButton.style, {
            padding: '8px 16px',
            backgroundColor: '#ffa500', // Orange color for distinction
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            marginLeft: '10px'
        });
        buttonsContainer.appendChild(refreshButton);

        // Save button
        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save';
        Object.assign(saveButton.style, {
            padding: '8px 16px',
            backgroundColor: '#4CAF50',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            marginLeft: '10px'
        });
        buttonsContainer.appendChild(saveButton);

        // Cancel button
        const cancelButton = document.createElement('button');
        cancelButton.textContent = 'Cancel';
        Object.assign(cancelButton.style, {
            padding: '8px 16px',
            backgroundColor: '#f44336',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            marginLeft: '10px'
        });
        buttonsContainer.appendChild(cancelButton);

        modalContent.appendChild(buttonsContainer);
        modalOverlay.appendChild(modalContent);
        document.body.appendChild(modalOverlay);

        // Make the list sortable using HTML5 Drag and Drop
        makeListSortable(list);

        // Event listeners
        cancelButton.addEventListener('click', () => {
            modalOverlay.remove();
        });

        saveButton.addEventListener('click', () => {
            const newSortList = [];
            const items = list.querySelectorAll('li');
            items.forEach(li => {
                const name = li.getAttribute('data-name');
                const found = sortList.find(item => item.name === name);
                if (found) newSortList.push(found);
            });

            // Update the sort list with any new GPTs
            const allGPTs = getAllGPTs();
            allGPTs.forEach(gpt => {
                const nameElement = gpt.querySelector('div.text-sm.text-token-text-primary'); // Update selector if necessary
                const name = nameElement ? nameElement.textContent.trim() : '';
                if (!newSortList.find(item => item.name === name)) {
                    newSortList.push({
                        name: name,
                        url: gpt.getAttribute('href'),
                        icon: gpt.querySelector('img') ? gpt.querySelector('img').src : ''
                    });
                }
            });

            saveCustomSort(newSortList);
            reorderGPTs(newSortList);
            modalOverlay.remove();
        });

        // Export functionality
        exportButton.addEventListener('click', () => {
            const dataStr = JSON.stringify(sortList, null, 2);
            const blob = new Blob([dataStr], { type: 'application/json' });
            const url = URL.createObjectURL(blob);

            const a = document.createElement('a');
            a.href = url;
            a.download = 'gpt_sort_order.json';
            a.click();

            URL.revokeObjectURL(url);
        });

        // Import functionality
        importButton.addEventListener('click', () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = 'application/json';
            input.onchange = e => {
                const file = e.target.files[0];
                if (!file) return;

                const reader = new FileReader();
                reader.onload = event => {
                    try {
                        const importedSortList = JSON.parse(event.target.result);
                        if (Array.isArray(importedSortList)) {
                            saveCustomSort(importedSortList);
                            reorderGPTs(importedSortList);
                            modalOverlay.remove();
                            alert('Sort order imported successfully.');
                        } else {
                            throw new Error('Invalid sort list format.');
                        }
                    } catch (err) {
                        alert('Failed to import sort order: ' + err.message);
                    }
                };
                reader.readAsText(file);
            };
            input.click();
        });

        // Refresh functionality
        refreshButton.addEventListener('click', () => {
            const currentSort = loadCustomSort();
            if (currentSort) {
                reorderGPTs(currentSort);
                alert('GPT list refreshed based on the current sort order.');
            } else {
                alert('No custom sort list found.');
            }
        });
    }

    // Function to make a list sortable using Drag and Drop
    function makeListSortable(list) {
        let draggedItem = null;

        list.addEventListener('dragstart', (e) => {
            if (e.target.tagName.toLowerCase() === 'li') {
                draggedItem = e.target;
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/html', e.target.innerHTML);
                e.target.style.opacity = '0.5';
            }
        });

        list.addEventListener('dragover', (e) => {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';
            const target = e.target;
            if (target && target !== draggedItem && target.nodeName === 'LI') {
                const rect = target.getBoundingClientRect();
                const next = (e.clientY - rect.top) > (rect.height / 2);
                list.insertBefore(draggedItem, next ? target.nextSibling : target);
            }
        });

        list.addEventListener('dragend', (e) => {
            if (draggedItem) {
                draggedItem.style.opacity = '1';
                draggedItem = null;
            }
        });

        // Make list items draggable
        const items = list.querySelectorAll('li');
        items.forEach(item => {
            item.setAttribute('draggable', 'true');
        });
    }

    // Function to create and inject the "Reorder GPTs" button
    function injectSortButton() {
        const existingButton = document.getElementById(REORDER_BUTTON_ID);
        if (existingButton) return; // Prevent duplicate buttons

        const button = document.createElement('button');
        button.id = REORDER_BUTTON_ID;
        button.textContent = 'Reorder GPTs';
        Object.assign(button.style, {
            padding: '8px 16px',
            margin: '10px',
            backgroundColor: '#008CBA',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer'
        });

        button.addEventListener('click', () => {
            const sortList = loadCustomSort();
            if (sortList) {
                createSortModal(sortList);
            } else {
                alert('No custom sort list found.');
            }
        });

        // Insert the button above the GPT list
        const gptContainer = getGPTContainer();
        if (gptContainer) {
            // Insert the button at the top of the container
            gptContainer.insertBefore(button, gptContainer.firstChild);
            console.log('"Reorder GPTs" button injected.');

            // Attach event delegation for GPT clicks
            //attachGPTClickListener(gptContainer);
        } else {
            console.warn('GPT container not found. Cannot inject the "Reorder GPTs" button.');
        }
    }

    // Function to attach a click listener to GPT items to reapply sort when a GPT is clicked
    async function attachGPTClickListener(gptContainer) {
        gptContainer.addEventListener('click', function(event) {
            reloadAll(event);
        });
    }

    async function reloadAll(event) {
        setTimeout(function() {
            const gptItem = event.target.closest('a.group.flex.h-10');
            if (gptItem) {
                console.log('GPT item clicked:', gptItem);

                // Wait for the sidebar button to load
                const buttons = Array.from(document.querySelectorAll('button'));
                let sidebarButton = null;
                for (let btn of buttons) {
                    if (btn.textContent.toLowerCase().includes('more')) {
                        sidebarButton = btn;
                    }
                    // Add other identification logic if needed
                }
                if (!sidebarButton) {
                    throw new Error('Sidebar button could not be identified.');
                }
                console.log('Sidebar button found:', sidebarButton);

                // Click the button to load all GPTs
                sidebarButton.click();
                console.log('Sidebar button clicked to load all GPTs.');

                // Wait for GPTs to load
                waitForElement(() => getAllGPTs().length > 0, 20000);
                console.log('GPTs loaded.');

                // Initialize or load custom sort
                let sortList = loadCustomSort();
                if (!sortList) {
                    sortList = initializeCustomSort();
                    console.log('Custom sort initialized with default order.');
                } else {
                    console.log('Custom sort loaded from localStorage.');
                }

                // Reorder GPTs
                reorderGPTs(sortList);
                console.log('GPTs reordered based on custom sort.');

                // Inject the "Reorder GPTs" button
                injectSortButton();
            }
        }, 2000);
    }

    // Attach event listener for 'popstate' to handle URL changes caused by browser navigation
    window.addEventListener('popstate', function(event) {
        // Your code here
        console.log('URL changed:', window.location.href);
        main();
    });

    let url = window.location.href;

    // Optional: Use a MutationObserver to detect URL changes not caused by popstate (e.g., SPA routing)
    const observer = new MutationObserver(() => {

        if (window.location.href != url){
            url = window.location.href;
            main();
        }
        //main();
    });

    // Observe changes to the document's title (as an example, adjust if necessary)
    observer.observe(document, { subtree: true, childList: true });

    // Main function to orchestrate the script
    async function main() {
        try {
            console.log('ChatGPT Sidebar GPT Reorder script started.');

            // Wait for the sidebar button to load
            const sidebarButton = await waitForElement(identifyTargetButton, 20000);
            if (!sidebarButton) {
                throw new Error('Sidebar button could not be identified.');
            }
            console.log('Sidebar button found:', sidebarButton);

            // Click the button to load all GPTs
            sidebarButton.click();
            console.log('Sidebar button clicked to load all GPTs.');

            // Wait for GPTs to load
            await waitForElement(() => getAllGPTs().length > 0, 20000);
            console.log('GPTs loaded.');

            // Initialize or load custom sort
            let sortList = loadCustomSort();
            if (!sortList) {
                sortList = initializeCustomSort();
                console.log('Custom sort initialized with default order.');
            } else {
                console.log('Custom sort loaded from localStorage.');
            }

            // Reorder GPTs
            reorderGPTs(sortList);
            console.log('GPTs reordered based on custom sort.');

            // Inject the "Reorder GPTs" button
            injectSortButton();

            // Optional: Use a MutationObserver to detect URL changes not caused by popstate (e.g., SPA routing)
            //const observer = new MutationObserver(() => {
            //    onURLChange();
            //});

            // Observe changes to the document's title (as an example, adjust if necessary)
            //observer.observe(document, { subtree: true, childList: true });

        } catch (error) {
            console.error('ChatGPT Sidebar GPT Reorder script error:', error);
        }
    }

    // Run the main function after DOM is fully loaded
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。