Greasy Fork is available in English.

GitHub Project Filter Formatter with Saved Filters

Adds fuzzy search button inside input and frequently used filters toolbar above GitHub project filter input.

ของเมื่อวันที่ 27-06-2025 ดู เวอร์ชันล่าสุด

// ==UserScript==
// @name         GitHub Project Filter Formatter with Saved Filters
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  Adds fuzzy search button inside input and frequently used filters toolbar above GitHub project filter input.
// @author       xiaohaoxing
// @match        https://github.com/orgs/.*/projects/.*
// @match        https://github.com/*/*/projects/*
// @grant        none
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const INPUT_ID = 'filter-bar-component-input';
    const BUTTON_ID = 'tampermonkey-format-title-button';
    const BUTTON_TEXT = '模糊';
    const SAVE_BUTTON_ID = 'tampermonkey-save-filter-button';
    const SAVE_BUTTON_TEXT = '⭐';
    const DROPDOWN_ID = 'tampermonkey-saved-filters-dropdown';
    const TOOLBAR_ID = 'tampermonkey-filter-toolbar';
    const WRAPPER_CLASS = 'tampermonkey-input-wrapper';
    const MIN_INPUT_TEXT_AREA_WIDTH = 50;
    const STORAGE_KEY = 'github-project-saved-filters';

    let filterInput = null;
    let formatButton = null;
    let saveButton = null;
    let savedFiltersDropdown = null;
    let toolbar = null;
    let inputWrapper = null;
    let resizeObserver = null;

    function getSavedFilters() {
        const saved = localStorage.getItem(STORAGE_KEY);
        return saved ? JSON.parse(saved) : [];
    }

    function saveFilter(name, value) {
        const filters = getSavedFilters();
        const newFilter = { name, value, id: Date.now() };
        filters.push(newFilter);
        localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
        updateDropdown();
        return newFilter;
    }

    function deleteFilter(id) {
        const filters = getSavedFilters();
        const updated = filters.filter(f => f.id !== id);
        localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
        updateDropdown();
    }

    function showManageFiltersDialog() {
        const filters = getSavedFilters();
        if (filters.length === 0) {
            alert('没有保存的筛选条件');
            return;
        }

        let message = '已保存的筛选条件:\n\n';
        filters.forEach((filter, index) => {
            message += `${index + 1}. ${filter.name}\n   值: ${filter.value}\n\n`;
        });
        message += '\n输入要删除的筛选条件编号(1-' + filters.length + '),或点击取消:';

        const input = prompt(message);
        if (input) {
            const index = parseInt(input) - 1;
            if (index >= 0 && index < filters.length) {
                const filterToDelete = filters[index];
                if (confirm(`确定要删除"${filterToDelete.name}"吗?`)) {
                    deleteFilter(filterToDelete.id);
                    alert('筛选条件已删除');
                }
            } else {
                alert('无效的编号');
            }
        }
    }

    function applyFilter(value) {
        const currentDomInput = document.getElementById(INPUT_ID);
        if (!currentDomInput) return;
        filterInput = currentDomInput;
        
        const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set ||
                            Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
        if (valueSetter) {
            valueSetter.call(filterInput, value);
        } else {
            filterInput.value = value;
        }
        const inputEvent = new Event('input', { bubbles: true, cancelable: true });
        filterInput.dispatchEvent(inputEvent);
        filterInput.focus();
    }

    function createSaveButton() {
        const existingButton = document.getElementById(SAVE_BUTTON_ID);
        if (existingButton) {
            return existingButton;
        }

        const button = document.createElement('button');
        button.id = SAVE_BUTTON_ID;
        button.textContent = SAVE_BUTTON_TEXT;
        button.type = 'button';
        button.title = '保存当前筛选条件';
        button.className = 'btn btn-sm';
        button.style.fontSize = '12px';
        button.style.lineHeight = '18px';
        button.style.height = '28px';

        // Override specific styles to ensure consistency
        button.style.display = 'inline-flex';
        button.style.alignItems = 'center';
        button.style.gap = '4px';
        button.style.marginLeft = '0';
        button.style.padding = '4px 8px';
        button.style.minWidth = 'auto';
        button.style.fontWeight = '500';

        // Add hover effects
        button.addEventListener('mouseenter', () => {
            button.classList.add('btn-hover');
        });
        button.addEventListener('mouseleave', () => {
            button.classList.remove('btn-hover');
        });

        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            
            const currentDomInput = document.getElementById(INPUT_ID);
            if (!currentDomInput) return;
            filterInput = currentDomInput;
            
            if (filterInput.value.trim() !== '') {
                const filterName = prompt('请输入筛选条件的名称:', filterInput.value.substring(0, 20));
                if (filterName && filterName.trim()) {
                    saveFilter(filterName.trim(), filterInput.value);
                    alert('筛选条件已保存!');
                }
            } else {
                alert('请先输入筛选条件');
            }
        });

        return button;
    }

    function createDropdown() {
        const existingDropdown = document.getElementById(DROPDOWN_ID);
        if (existingDropdown) {
            return existingDropdown;
        }

        const dropdown = document.createElement('select');
        dropdown.id = DROPDOWN_ID;
        dropdown.className = 'form-select select-sm';
        
        // Override specific styles
        dropdown.style.minWidth = '120px';
        dropdown.style.maxWidth = '200px';
        dropdown.style.marginRight = '8px'; // Small gap between dropdown and button
        dropdown.style.height = '28px';
        dropdown.style.fontSize = '12px';
        dropdown.style.lineHeight = '18px';
        dropdown.style.paddingLeft = '12px';
        dropdown.style.paddingRight = '32px';
        dropdown.style.border = '1px solid var(--color-border-default, rgba(31, 35, 40, 0.15))';
        dropdown.style.borderRadius = '6px';
        dropdown.style.backgroundColor = 'var(--color-canvas-default, #ffffff)';
        dropdown.style.color = 'var(--color-fg-default, #24292f)';
        dropdown.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg width=\'16\' height=\'16\' viewBox=\'0 0 16 16\' fill=\'%23586069\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z\'/%3E%3C/svg%3E")';
        dropdown.style.backgroundRepeat = 'no-repeat';
        dropdown.style.backgroundPosition = 'right 8px center';
        dropdown.style.backgroundSize = '16px';
        dropdown.style.appearance = 'none';
        dropdown.style.cursor = 'pointer';

        // Add hover effect
        dropdown.addEventListener('mouseenter', () => {
            dropdown.style.borderColor = 'var(--color-border-muted, rgba(31, 35, 40, 0.3))';
            dropdown.style.backgroundColor = 'var(--color-canvas-subtle, #f6f8fa)';
        });
        dropdown.addEventListener('mouseleave', () => {
            dropdown.style.borderColor = 'var(--color-border-default, rgba(31, 35, 40, 0.15))';
            dropdown.style.backgroundColor = 'var(--color-canvas-default, #ffffff)';
        });

        // Add focus styles
        dropdown.addEventListener('focus', () => {
            dropdown.style.outline = '2px solid var(--color-accent-fg, #0969da)';
            dropdown.style.outlineOffset = '-1px';
        });
        dropdown.addEventListener('blur', () => {
            dropdown.style.outline = 'none';
        });

        dropdown.addEventListener('change', (event) => {
            const selectedValue = event.target.value;
            if (!selectedValue) return;

            if (selectedValue === 'manage') {
                // Show management dialog
                showManageFiltersDialog();
            } else {
                const selectedId = parseInt(selectedValue);
                const filters = getSavedFilters();
                const filter = filters.find(f => f.id === selectedId);
                
                if (filter) {
                    applyFilter(filter.value);
                }
            }
            
            dropdown.value = '';
        });

        updateDropdown(dropdown);
        return dropdown;
    }

    function updateDropdown(dropdown) {
        dropdown = dropdown || document.getElementById(DROPDOWN_ID);
        if (!dropdown) return;

        const filters = getSavedFilters();
        dropdown.innerHTML = '';

        const defaultOption = document.createElement('option');
        defaultOption.value = '';
        defaultOption.textContent = filters.length > 0 ? '常用筛选...' : '无保存的筛选';
        dropdown.appendChild(defaultOption);

        filters.forEach(filter => {
            const option = document.createElement('option');
            option.value = filter.id;
            option.textContent = filter.name;
            option.title = filter.value;
            dropdown.appendChild(option);
        });

        // Add manage option if there are saved filters
        if (filters.length > 0) {
            const separator = document.createElement('option');
            separator.disabled = true;
            separator.textContent = '────────';
            dropdown.appendChild(separator);

            const manageOption = document.createElement('option');
            manageOption.value = 'manage';
            manageOption.textContent = '🗑️ 管理筛选条件';
            manageOption.style.fontStyle = 'italic';
            dropdown.appendChild(manageOption);
        }

        dropdown.style.display = filters.length > 0 ? 'block' : 'none';
    }

    function createFormatButton() {
        const existingButton = document.getElementById(BUTTON_ID);
        if (existingButton) {
            return existingButton;
        }

        const button = document.createElement('button');
        button.id = BUTTON_ID;
        button.textContent = BUTTON_TEXT;
        button.type = 'button';
        button.title = '转换为模糊搜索格式';
        button.className = 'btn-sm';

        // Style for button inside input with GitHub native look
        button.style.position = 'absolute';
        button.style.top = '50%';
        button.style.right = '28px';
        button.style.transform = 'translateY(-50%)';
        button.style.zIndex = '10';
        button.style.padding = '3px 8px';
        button.style.fontSize = '12px';
        button.style.height = '24px';
        button.style.lineHeight = '1';
        button.style.display = 'inline-flex';
        button.style.alignItems = 'center';
        button.style.justifyContent = 'center';
        button.style.border = '1px solid var(--color-border-default, rgba(31, 35, 40, 0.15))';
        button.style.borderRadius = '6px';
        button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
        button.style.color = 'var(--color-fg-default, #24292f)';
        button.style.cursor = 'pointer';
        button.style.fontWeight = '500';
        button.style.whiteSpace = 'nowrap';
        button.style.transition = 'background-color 0.2s, border-color 0.2s';

        button.addEventListener('mouseenter', () => {
            button.style.backgroundColor = 'var(--color-btn-hover-bg, #f3f4f6)';
            button.style.borderColor = 'var(--color-border-muted, rgba(31, 35, 40, 0.3))';
        });
        button.addEventListener('mouseleave', () => {
            button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
            button.style.borderColor = 'var(--color-border-default, rgba(31, 35, 40, 0.15))';
        });

        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            const currentDomInput = document.getElementById(INPUT_ID);
            if (!currentDomInput) return;
            filterInput = currentDomInput;

            if (filterInput.value.trim() !== '') {
                const originalValue = filterInput.value;
                const newValue = `title:*${originalValue}*`;
                applyFilter(newValue);
            }
        });
        return button;
    }

    function createToolbar() {
        const existingToolbar = document.getElementById(TOOLBAR_ID);
        if (existingToolbar) {
            return existingToolbar;
        }

        const toolbar = document.createElement('div');
        toolbar.id = TOOLBAR_ID;
        toolbar.style.display = 'flex';
        toolbar.style.alignItems = 'center';
        toolbar.style.marginBottom = '4px';
        toolbar.style.gap = '0';
        toolbar.style.paddingLeft = '0';
        toolbar.style.paddingRight = '0';
        toolbar.style.width = 'auto';

        return toolbar;
    }

    function adjustLayout() {
        if (!filterInput || !inputWrapper || !formatButton) return;

        // Ensure input fills the wrapper and has box-sizing: border-box
        filterInput.style.width = '100%';
        filterInput.style.boxSizing = 'border-box';

        // Temporarily show button to measure its width if it was hidden
        const wasButtonHidden = formatButton.style.display === 'none';
        if (wasButtonHidden) {
            formatButton.style.visibility = 'hidden';
            formatButton.style.display = 'inline-flex';
        }

        const buttonActualWidth = formatButton.offsetWidth;

        if (wasButtonHidden) {
            formatButton.style.display = 'none';
            formatButton.style.visibility = 'visible';
        }

        const wrapperInnerWidth = inputWrapper.clientWidth -
                                 (parseFloat(getComputedStyle(inputWrapper).paddingLeft) || 0) -
                                 (parseFloat(getComputedStyle(inputWrapper).paddingRight) || 0);

        const spaceForButtonAndGap = buttonActualWidth + 16; // Button width + gap from right edge

        // Check if there's enough space for the button AND a minimum text area
        if (buttonActualWidth > 0 && (wrapperInnerWidth - spaceForButtonAndGap) >= MIN_INPUT_TEXT_AREA_WIDTH) {
            filterInput.style.paddingRight = `${spaceForButtonAndGap}px`;
            formatButton.style.display = 'inline-flex';
        } else {
            // Not enough space, hide button and reset padding
            filterInput.style.paddingRight = '8px';
            formatButton.style.display = 'none';
        }
    }

    function setupInputAndButton() {
        const currentDomInput = document.getElementById(INPUT_ID);

        if (!currentDomInput) {
            // Input disappeared, cleanup
            if (resizeObserver && inputWrapper) {
                resizeObserver.unobserve(inputWrapper);
            }
            if (formatButton && formatButton.parentNode) formatButton.parentNode.removeChild(formatButton);
            if (toolbar && toolbar.parentNode) toolbar.parentNode.removeChild(toolbar);
            if (inputWrapper && inputWrapper.parentNode && inputWrapper.classList.contains(WRAPPER_CLASS)) {
                const originalParent = inputWrapper.parentNode;
                if (filterInput && document.body.contains(filterInput) && filterInput.parentElement !== originalParent) {
                    originalParent.insertBefore(filterInput, inputWrapper);
                    filterInput.style.paddingRight = '';
                    filterInput.style.width = '';
                }
                originalParent.removeChild(inputWrapper);
            }
            filterInput = null;
            inputWrapper = null;
            formatButton = null;
            toolbar = null;
            saveButton = null;
            savedFiltersDropdown = null;
            return;
        }

        filterInput = currentDomInput;

        // Setup input wrapper for fuzzy button
        if (filterInput.parentElement && filterInput.parentElement.classList.contains(WRAPPER_CLASS)) {
            inputWrapper = filterInput.parentElement;
        } else {
            inputWrapper = document.createElement('div');
            inputWrapper.classList.add(WRAPPER_CLASS);
            inputWrapper.style.position = 'relative';

            // Mimic original input's display
            const originalInputComputedStyle = getComputedStyle(filterInput);
            inputWrapper.style.display = originalInputComputedStyle.display;
            
            if (originalInputComputedStyle.display === 'block' || originalInputComputedStyle.display === 'flex') {
                inputWrapper.style.width = '100%';
            } else if (originalInputComputedStyle.display === 'inline-block') {
                if (originalInputComputedStyle.width !== 'auto' && !originalInputComputedStyle.width.includes('%')) {
                    inputWrapper.style.width = originalInputComputedStyle.width;
                }
            }
            
            if (filterInput.parentElement && getComputedStyle(filterInput.parentElement).display.includes('flex')) {
                inputWrapper.style.flexGrow = originalInputComputedStyle.flexGrow;
                inputWrapper.style.flexShrink = originalInputComputedStyle.flexShrink;
                inputWrapper.style.flexBasis = originalInputComputedStyle.flexBasis;
            }

            if (filterInput.parentNode) {
                filterInput.parentNode.insertBefore(inputWrapper, filterInput);
                inputWrapper.appendChild(filterInput);
            } else {
                console.warn("[Tampermonkey] Filter input has no parent, cannot wrap.");
                return;
            }
        }

        // Add fuzzy button to input wrapper
        if (!formatButton || !inputWrapper.contains(formatButton)) {
            formatButton = createFormatButton();
            inputWrapper.appendChild(formatButton);
        }

        // Setup toolbar above input
        // Find the filter-bar-module__Filter_0--v8FnK div which is the form container
        let formContainer = filterInput.closest('[id="filter-bar-component"]');
        if (!formContainer) {
            console.warn("[Tampermonkey] Could not find filter-bar-component");
            return;
        }

        // Find the parent that contains the entire filter section
        let filterContainer = formContainer.closest('.tokenized-filter-input-module__Box--w5A7b');
        if (!filterContainer) {
            // Try alternative selector for the filter container
            filterContainer = formContainer.parentElement;
            if (!filterContainer) {
                console.warn("[Tampermonkey] Could not find tokenized filter container");
                return;
            }
        }

        // Create or find toolbar
        if (!toolbar || !document.body.contains(toolbar)) {
            toolbar = createToolbar();
            
            // Insert toolbar before the filter container
            if (filterContainer.parentNode) {
                filterContainer.parentNode.insertBefore(toolbar, filterContainer);
            } else {
                console.warn("[Tampermonkey] Could not insert toolbar");
                return;
            }
        }

        // Add dropdown and save button to toolbar
        if (!savedFiltersDropdown || !toolbar.contains(savedFiltersDropdown)) {
            savedFiltersDropdown = createDropdown();
            toolbar.appendChild(savedFiltersDropdown);
        }

        if (!saveButton || !toolbar.contains(saveButton)) {
            saveButton = createSaveButton();
            toolbar.appendChild(saveButton);
        }

        // Initial layout adjustment
        adjustLayout();

        // Observe wrapper for size changes
        if (typeof ResizeObserver !== 'undefined') {
            if (resizeObserver) {
                resizeObserver.disconnect();
            }
            resizeObserver = new ResizeObserver(entries => {
                requestAnimationFrame(adjustLayout);
            });
            if (inputWrapper) {
                resizeObserver.observe(inputWrapper);
            }
        }

        // Update dropdown visibility
        updateDropdown();
    }

    const mutationObserver = new MutationObserver(() => {
        const currentInput = document.getElementById(INPUT_ID);
        if (currentInput) {
            if (!inputWrapper || !inputWrapper.contains(currentInput) || !formatButton || !inputWrapper.contains(formatButton) || !toolbar || !document.body.contains(toolbar)) {
                setupInputAndButton();
            }
        } else if (filterInput) {
            setupInputAndButton(); // This will trigger the cleanup logic
        }
    });

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

    setTimeout(setupInputAndButton, 1000);

})();