Userscript addons for missing features

After a couple of weeks using Vikujan, Kanban is not totally productive.
That’s why I had some features based on userscript.

Vikunja filter

Press “/” to filter tasks live. Save/load small filter presets (only the filter= param). Export/import presets. Prompts Replace/Merge when applying. Origin-scoped storage.

// ==UserScript==
// @name         Vikunja filter (origin storage + prompt apply)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Press "/" to filter tasks live. Save/load small filter presets (only the `filter=` param). Export/import presets. Prompts Replace/Merge when applying. Origin-scoped storage.
// @match        https://TODO/*
// @grant        none
// ==/UserScript==

(function() {
    "use strict";

    /* -------------------------------------------------------
       SETTINGS
    --------------------------------------------------------- */
    const STORAGE_KEY = "kanban_filters_persistent_v2_origin";  // origin-scoped storage
    const AUTO_DARK = true;
    const FORCE_DARK = false;

    function isDarkMode() {
        if (FORCE_DARK) return true;
        if (!AUTO_DARK) return false;
        return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
    }
    const DARK = isDarkMode();

    const colors = DARK ? {
        bg: "#1f1f1f",
        panel: "#262626",
        border: "#333",
        text: "#eee",
        button: "#3a6ee8",
        shadow: "rgba(0,0,0,0.6)"
    } : {
        bg: "#fff",
        panel: "#f7f7f7",
        border: "#ccc",
        text: "#333",
        button: "#4a90e2",
        shadow: "rgba(0,0,0,0.25)"
    };

    /* -------------------------------------------------------
       STORAGE HELPERS (origin-scoped)
    --------------------------------------------------------- */
    function loadFilters() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
        } catch (e) {
            console.warn("Failed to parse saved filters, resetting.", e);
            return {};
        }
    }

    function saveFilters(obj) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
    }

    function getBaseKey() {
        return window.location.origin; // <-- origin-scoped
    }

    /* -------------------------------------------------------
       UI: Floating button + sidebar
    --------------------------------------------------------- */
    const floatingBtn = document.createElement("div");
    floatingBtn.textContent = "⚙️ Filters";
    floatingBtn.style.cssText = `
        position: fixed; bottom: 20px; right: 20px;
        background: ${colors.button}; color: white;
        padding: 10px 14px; border-radius: 30px; cursor: pointer;
        font-size: 14px; z-index: 999999; box-shadow: 0 4px 12px ${colors.shadow};
        user-select: none;
    `;
    document.body.appendChild(floatingBtn);

    const sidebar = document.createElement("div");
    sidebar.style.cssText = `
        position: fixed; top: 0; right: -380px; width: 360px; height: 100%;
        background: ${colors.panel}; color: ${colors.text}; border-left: 1px solid ${colors.border};
        box-shadow: -4px 0 20px ${colors.shadow}; z-index: 999998; transition: right 0.25s ease;
        padding: 14px; font-family: sans-serif; box-sizing: border-box;
    `;
    sidebar.innerHTML = `
        <h3 style="margin:0 0 12px 0;">Saved Filters</h3>
        <div style="display:flex; gap:8px; margin-bottom:12px;">
            <button id="addFilterBtn" style="
                flex:1; padding:8px 0; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">+ Add Current Filter</button>
            <button id="exportFiltersBtn" style="
                padding:8px 10px; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">⬇️</button>
            <button id="importFiltersBtn" style="
                padding:8px 10px; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">⬆️</button>
        </div>
        <input type="file" id="filtersFileInput" style="display:none" accept="application/json">
        <div id="savedFilters" style="max-height:78%; overflow-y:auto;"></div>
        <div style="opacity:.8; font-size:12px; margin-top:10px;">
            <div>Click title to apply (Replace). When applying you will be asked Replace / Merge / Cancel.</div>
            <div>Saved value contains only the <code>filter</code> parameter.</div>
        </div>
    `;
    document.body.appendChild(sidebar);

    const savedFiltersDiv = sidebar.querySelector("#savedFilters");
    const addFilterBtn = sidebar.querySelector("#addFilterBtn");
    const exportFiltersBtn = sidebar.querySelector("#exportFiltersBtn");
    const importFiltersBtn = sidebar.querySelector("#importFiltersBtn");
    const filtersFileInput = sidebar.querySelector("#filtersFileInput");

    function openSidebar() {
        sidebar.style.right = "0";
        renderFilters();
    }
    function closeSidebar() {
        sidebar.style.right = "-380px";
    }
    floatingBtn.onclick = openSidebar;
    document.addEventListener("keydown", e => {
        if (e.key === "Escape") {
            if (sidebar.style.right === "0px" || sidebar.style.right === "0") closeSidebar();
            else resetSearch();
        }
    });

    /* -------------------------------------------------------
       Utility: merge two filter strings (comma-separated),
       avoid duplicates and empty parts, preserve order.
    --------------------------------------------------------- */
    function mergeFilterStrings(a, b) {
        // split by comma, trim, filter out empty, keep unique while preserving order
        const parts = [];
        function pushParts(s) {
            if (!s) return;
            s.split(",").forEach(p => {
                const t = p.trim();
                if (!t) return;
                if (!parts.includes(t)) parts.push(t);
            });
        }
        pushParts(a);
        pushParts(b);
        return parts.join(",");
    }

    /* -------------------------------------------------------
       APPLY FILTER — prompts user to Replace / Merge / Cancel
    --------------------------------------------------------- */
    function applyFilterWithPrompt(savedEntry) {
        // extract saved filter (backwards compat if older .url exists)
        let saved = typeof savedEntry.filter === "string" ? savedEntry.filter : "";
        if ((!saved || saved === "") && savedEntry.url) {
            try {
                const old = new URL(savedEntry.url, window.location.origin);
                saved = old.searchParams.get("filter") || "";
            } catch (err) {
                saved = "";
            }
        }

        // Ask user how to apply
        const choice = window.prompt(
`Apply filter "${savedEntry.title || "(untitled)"}":
R = Replace (set filter=...),
M = Merge (append, avoid duplicates),
C = Cancel
Type R / M / C (default R)`,
"R"
        );

        if (choice === null) return; // cancelled prompt (treat as Cancel)

        const normalized = String(choice).trim().toUpperCase();
        if (normalized === "C") return;

        const url = new URL(window.location.href);

        if (normalized === "M") {
            const current = url.searchParams.get("filter") || "";
            const merged = mergeFilterStrings(current, saved);
            if (merged === "") url.searchParams.delete("filter");
            else url.searchParams.set("filter", merged);
        } else {
            // default Replace
            if ((saved || "").trim() === "") url.searchParams.delete("filter");
            else url.searchParams.set("filter", saved.trim());
        }

        window.location.href = url.toString();
    }

    /* -------------------------------------------------------
       Render saved filters (origin scoped)
    --------------------------------------------------------- */
    function buildBtnStyle() {
        return `
            padding:6px; cursor:pointer;
            background:${colors.panel}; color:${colors.text};
            border:1px solid ${colors.border}; border-radius:6px;
            min-width:40px; font-size:13px;
        `;
    }

    function renderFilters() {
        const all = loadFilters();
        const base = getBaseKey();
        const list = all[base] || [];
        savedFiltersDiv.innerHTML = "";

        if (!Array.isArray(list) || list.length === 0) {
            savedFiltersDiv.innerHTML = `<div style="opacity:.6;">No saved filters</div>`;
            return;
        }

        list.forEach((f, i) => {
            const row = document.createElement("div");
            row.style.cssText = `
                display:flex; flex-direction:column; background:${colors.bg}; color:${colors.text};
                padding:8px; border:1px solid ${colors.border}; border-radius:6px; margin-bottom:10px; box-sizing:border-box;
            `;

            const title = document.createElement("div");
            title.textContent = f.title || "(untitled)";
            title.style.cssText = `cursor:pointer; font-size:15px; font-weight:600; margin-bottom:6px;`;
            title.onclick = () => applyFilterWithPrompt(f);
            row.appendChild(title);

            const filterText = document.createElement("div");
            filterText.textContent = (typeof f.filter === "string" && f.filter !== "") ? f.filter : "(empty filter)";
            filterText.style.cssText = `font-size:13px; opacity:0.88; margin-bottom:8px; word-break:break-all;`;
            row.appendChild(filterText);

            const btnRow = document.createElement("div");
            btnRow.style.cssText = `display:flex; gap:6px;`;

            const editBtn = document.createElement("button");
            editBtn.textContent = "✏️";
            editBtn.title = "Edit title and filter";
            editBtn.style.cssText = buildBtnStyle();
            editBtn.onclick = () => {
                const newTitle = prompt("New title:", f.title || "");
                if (newTitle !== null) f.title = newTitle;
                const newFilter = prompt("New filter (value of `filter=`):", f.filter || "");
                if (newFilter !== null) f.filter = newFilter;

                const allData = loadFilters();
                allData[getBaseKey()] = allData[getBaseKey()] || [];
                allData[getBaseKey()][i] = f;
                saveFilters(allData);
                renderFilters();
            };

            const copyBtn = document.createElement("button");
            copyBtn.textContent = "📋";
            copyBtn.title = "Copy filter value to clipboard";
            copyBtn.style.cssText = buildBtnStyle();
            copyBtn.onclick = async () => {
                try {
                    await navigator.clipboard.writeText(f.filter || "");
                    copyBtn.textContent = "✅";
                    setTimeout(() => (copyBtn.textContent = "📋"), 900);
                } catch (err) {
                    alert("Copy failed. Select and copy: " + (f.filter || ""));
                }
            };

            const mergeBtn = document.createElement("button");
            mergeBtn.textContent = "🔀";
            mergeBtn.title = "Merge with current filter (no prompt: merge)";
            mergeBtn.style.cssText = buildBtnStyle();
            mergeBtn.onclick = () => {
                // immediate merge action
                const url = new URL(window.location.href);
                const current = url.searchParams.get("filter") || "";
                const merged = mergeFilterStrings(current, f.filter || "");
                if (merged === "") url.searchParams.delete("filter");
                else url.searchParams.set("filter", merged);
                window.location.href = url.toString();
            };

            const delBtn = document.createElement("button");
            delBtn.textContent = "❌";
            delBtn.title = "Delete saved filter";
            delBtn.style.cssText = buildBtnStyle();
            delBtn.onclick = () => {
                if (!confirm(`Delete "${f.title}"?`)) return;
                const allData = loadFilters();
                const baseKey = getBaseKey();
                allData[baseKey] = allData[baseKey] || [];
                allData[baseKey].splice(i, 1);
                saveFilters(allData);
                renderFilters();
            };

            btnRow.appendChild(editBtn);
            btnRow.appendChild(copyBtn);
            btnRow.appendChild(mergeBtn);
            btnRow.appendChild(delBtn);
            row.appendChild(btnRow);
            savedFiltersDiv.appendChild(row);
        });
    }

    /* -------------------------------------------------------
       Add current filter (saves only filter param) — origin-scoped
    --------------------------------------------------------- */
    addFilterBtn.onclick = () => {
        const title = prompt("Filter title?");
        if (title === null) return;

        const base = getBaseKey();
        const all = loadFilters();
        if (!all[base]) all[base] = [];

        const params = new URLSearchParams(window.location.search);
        const filterValue = params.get("filter") || "";

        all[base].push({
            title,
            filter: filterValue
        });

        saveFilters(all);
        renderFilters();
    };

    /* -------------------------------------------------------
       Export / Import (JSON format)
    --------------------------------------------------------- */
    exportFiltersBtn.onclick = () => {
        const data = JSON.stringify(loadFilters(), null, 2);
        const blob = new Blob([data], { type: "application/json" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = "vikunja-filters.json";
        a.click();
        URL.revokeObjectURL(url);
    };

    importFiltersBtn.onclick = () => filtersFileInput.click();
    filtersFileInput.onchange = async () => {
        const file = filtersFileInput.files[0];
        if (!file) return;
        try {
            const text = await file.text();
            const json = JSON.parse(text);
            if (typeof json !== "object" || json === null) throw new Error("Invalid format");
            saveFilters(json);
            renderFilters();
            alert("Filters imported!");
        } catch (err) {
            console.error(err);
            alert("Invalid JSON file.");
        } finally {
            filtersFileInput.value = "";
        }
    };

    /* -------------------------------------------------------
       "/" live search (preserved)
    --------------------------------------------------------- */
    const searchInput = document.createElement("input");
    searchInput.placeholder = "Filter tasks…";
    searchInput.style.cssText = `
        position: fixed; top: 10px; left: 50%; transform: translateX(-50%);
        width: 320px; padding: 8px 12px; font-size: 16px; z-index: 999999;
        display:none; background: ${colors.bg}; color: ${colors.text};
        border: 2px solid ${colors.button}; border-radius: 6px; box-shadow: 0 4px 20px ${colors.shadow};
        box-sizing: border-box;
    `;
    document.body.appendChild(searchInput);

    const TASK_SELECTORS = ["li", ".task", ".item", "[data-task]"];
    function getTasks() {
        return Array.from(document.querySelectorAll(TASK_SELECTORS.join(",")))
            .filter(el => el.innerText.trim().length > 0);
    }
    function filterTasks(q) {
        q = q.toLowerCase();
        getTasks().forEach(t => {
            t.style.display = t.innerText.toLowerCase().includes(q) ? "" : "none";
        });
    }
    function resetSearch() {
        searchInput.value = "";
        searchInput.style.display = "none";
        filterTasks("");
    }

    document.addEventListener("keydown", e => {
        if (e.key === "/" && document.activeElement !== searchInput && !e.metaKey && !e.ctrlKey) {
            e.preventDefault();
            searchInput.style.display = "block";
            searchInput.focus();
            searchInput.select();
        }
        if (e.key === "Escape") {
            if (sidebar.style.right === "0px" || sidebar.style.right === "0") closeSidebar();
            else resetSearch();
        }
    });

    searchInput.addEventListener("input", () => filterTasks(searchInput.value));
    searchInput.addEventListener("keydown", e => {
        if (e.key === "Enter") {
            const visible = getTasks().filter(t => t.style.display !== "none");
            if (visible.length === 1) {
                visible[0].click();
                resetSearch();
            }
        }
    });

    /* -------------------------------------------------------
       INITIAL RENDER
    --------------------------------------------------------- */
    renderFilters();

})();

Vikunja Auto Label by Column

Auto-apply column labels when a task is moved between Kanban columns in Vikunja — resolves short IDs and label IDs via API.

// ==UserScript==
// @name         Vikunja Auto Label by Column
// @namespace    malys
// @version      2.1
// @description  Auto-apply column labels when a task is moved between Kanban columns in Vikunja — resolves short IDs and label IDs via API
// @match        https://TODO/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // ---------------------------
    // CONFIG
    // ---------------------------
    const DEBUG = false;                    // set to false to silence logs
    const TOKEN_KEY = 'token';             // change if your token is stored under a different key in localStorage
    const API_BASE_ORIGIN = window.location.origin; // uses same origin as the app
    const API_BASE = `${API_BASE_ORIGIN}/api/v1`;
    const DEBOUNCE_MS = 250;               // debounce processing after mutations

    const log = (...args) => { if (DEBUG) console.log('[AutoLabel]', ...args); };

    // ---------------------------
    // Auth helpers
    // ---------------------------
    function getToken() {
        return localStorage.getItem(TOKEN_KEY) || '';
    }

    function authHeaders(extra = {}) {
        const token = getToken();
        const base = { ...extra };
        if (token) base['Authorization'] = `Bearer ${token}`;
        if (!base['Accept']) base['Accept'] = 'application/json, text/plain, */*';
        return base;
    }

    // ---------------------------
    // URL helpers (project & view)
    // ---------------------------
    function getProjectAndViewFromUrl() {
        const m = window.location.pathname.match(/\/projects\/(\d+)\/(\d+)/);
        if (!m) return null;
        return { projectId: parseInt(m[1], 10), viewId: parseInt(m[2], 10) };
    }

    // ---------------------------
    // API helpers
    // ---------------------------
    async function fetchJson(url, opts = {}) {
        const res = await fetch(url, { credentials: 'include', ...opts });
        if (!res.ok) {
            const txt = await res.text().catch(() => '');
            throw new Error(`HTTP ${res.status}: ${txt}`);
        }
        return res.json();
    }

    async function loadViewTasks(projectId, viewId) {
        const url = `${API_BASE}/projects/${projectId}/views/${viewId}/tasks?filter=&filter_include_nulls=false&s=&per_page=100&page=1`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getAllLabels() {
        return await fetchJson(`${API_BASE}/labels`, { headers: authHeaders() });
    }

    async function createLabel(title) {
        return await fetchJson(`${API_BASE}/labels`, {
            method: 'PUT',
            headers: authHeaders({ 'Content-Type': 'application/json' }),
            body: JSON.stringify({ title })
        });
    }

    async function addLabelToTask(taskId, labelId) {
        // Vikunja UI used PUT to /tasks/:id/labels with a body — replicate that
        const url = `${API_BASE}/tasks/${taskId}/labels`;
        const body = JSON.stringify({ max_permission: null, id: 0, task_id: taskId, label_id: labelId });
        const res = await fetch(url, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, credentials: 'include' });
        if (!res.ok) throw new Error(`addLabel failed ${res.status}`);
        return res;
    }

    async function removeLabelFromTask(taskId, labelId) {
        const url = `${API_BASE}/tasks/${taskId}/labels/${labelId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function getTaskLabels(taskId) {
        return await fetchJson(`${API_BASE}/tasks/${taskId}/labels`, { headers: authHeaders() });
    }

    // ---------------------------
    // Utilities: normalize column/label name
    // ---------------------------
    const normalize = s => (String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ''));

    // ---------------------------
    // Extract short ID from card DOM
    // ---------------------------
    function extractShortIdFromCard(card) {
        
        try {
            const idNode = card.querySelector('.task-id');
            if (!idNode) return null;
            const text = idNode.textContent || '';
            return text.replace("Done","");
        } catch (e) {
            return null;
        }
    }

    // ---------------------------
    // Column name from card
    // ---------------------------
    function getColumnFromCard(card) {
        const bucket = card.closest('.bucket');
        if (!bucket) return null;
        const h2 = bucket.querySelector('h2.title');
        if (!h2) return null;
        return h2.textContent.trim();
    }

    // ---------------------------
    // Main state
    // ---------------------------
    const cardColumn = new WeakMap();         // track last known column per card element
    let shortIdToNumeric = {};                // map shortId -> numeric id
    let labelTitleToObj = {};                 // map normalized label title -> label object
    let lastLoadAt = 0;

    // ---------------------------
    // Load view tasks & labels and build maps
    // ---------------------------
    async function refreshMaps() {
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;

        log('Loading view tasks for', projectId, viewId);
        try {
            const viewCols = await loadViewTasks(projectId, viewId);
            shortIdToNumeric = {};
            for (const col of viewCols) {
                if (!col.tasks) continue;
                for (const task of col.tasks) {
                    if (task.identifier) shortIdToNumeric[task.identifier.toUpperCase()] = task.id;
                }
            }
            log('Built shortId->id map', shortIdToNumeric);
        } catch (e) {
            console.error('[AutoLabel] Failed to load view tasks:', e);
        }

        log('Loading labels');
        try {
            const labels = await getAllLabels();
            labelTitleToObj = {};
            for (const l of labels) {
                labelTitleToObj[normalize(l.title)] = l;
            }
            log('Built label map', Object.keys(labelTitleToObj));
        } catch (e) {
            console.error('[AutoLabel] Failed to load labels:', e);
        }

        lastLoadAt = Date.now();
    }

    // ---------------------------
    // Resolve numeric id from short id (uses prebuilt map, or refreshes if missing)
    // ---------------------------
    async function resolveNumericId(shortId) {
        if (!shortId) return null;
        shortId = shortId.toUpperCase();
        if (shortIdToNumeric[shortId]) return shortIdToNumeric[shortId];

        // try refresh once
        await refreshMaps();
        return shortIdToNumeric[shortId] || null;
    }

    // ---------------------------
    // Find or create label object for column name
    // ---------------------------
    async function ensureLabelForColumn(columnName) {
        const key = normalize(columnName);
        let label = labelTitleToObj[key];
        if (label) return label;

        log('Label for column not found, creating:', columnName);
        try {
            label = await createLabel(columnName);
            labelTitleToObj[normalize(label.title)] = label;
            return label;
        } catch (e) {
            console.error('[AutoLabel] Failed to create label:', e);
            return null;
        }
    }

    // ---------------------------
    // Core: handle a card move
    // ---------------------------
    async function handleCardMove(card) {
        try {
            const shortId = extractShortIdFromCard(card);
            if (DEBUG) log('shortId', shortId);
            if (!shortId) return;

            const numericId = await resolveNumericId(shortId);
            if (DEBUG) log('numericId', numericId);
            if (!numericId) {
                log('Could not resolve numeric id for', shortId);
                return;
            }

            const colName = getColumnFromCard(card);
            if (DEBUG) log('colName', colName);
            if (!colName) return;
            const normalizedCol = normalize(colName);

            log(`Task ${shortId} (${numericId}) moved to column '${colName}'`);

            // ensure label exists for this column
            const labelObj = await ensureLabelForColumn(colName);
            if (DEBUG) log('labelObj', labelObj);
            if (!labelObj) return;

            // fetch current labels on task
            let currentLabels = [];
            try {
                currentLabels = await getTaskLabels(numericId);
                if (DEBUG) log('currentLabels', currentLabels);
            } catch (e) {
                console.error('[AutoLabel] Failed to get task labels', e);
            }
            if(currentLabels==null){
                currentLabels=[];
            }

            // remove labels that don't match column
            for (const old of currentLabels) {
                if (normalize(old.title) !== normalizedCol) {
                    try {
                        log('Removing label', old.title, 'from task', numericId);
                        await removeLabelFromTask(numericId, old.id);
                    } catch (e) {
                        console.error('[AutoLabel] failed remove label', e);
                    }
                }
            }

            // add target label if not present
            const already = currentLabels.some(l => normalize(l.title) === normalizedCol);
            if (!already) {
                try {
                    log('Adding label', labelObj.title, 'to task', numericId);
                    await addLabelToTask(numericId, labelObj.id);
                } catch (e) {
                    console.error('[AutoLabel] failed add label', e);
                }
            } else {
                log('Task already has target label');
            }
        } catch (e) {
            console.error('[AutoLabel] handleCardMove exception', e);
        }
    }

    // ---------------------------
    // Process DOM: scan cards and detect column changes
    // ---------------------------
    async function processMoves() {
        // refresh maps periodically (every 60s) to stay in sync
        if (Date.now() - lastLoadAt > 60_000) await refreshMaps();

        document.querySelectorAll('.kanban-card').forEach(card => {
            const col = getColumnFromCard(card);
            if (!col) return;
            const prev = cardColumn.get(card);
            if (prev === col) return;
            cardColumn.set(card, col);
            handleCardMove(card);
        });
    }

    // ---------------------------
    // MutationObserver + debounce
    // ---------------------------
    let debounceTimer = null;
    const mo = new MutationObserver((mutations) => {
        if (DEBUG) log('Mutations', mutations.map(m => ({ type: m.type, added: m.addedNodes.length, removed: m.removedNodes.length })));
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => { Promise.resolve().then(processMoves); }, DEBOUNCE_MS);
    });

    // Extra: listen to dragend to force-scan
    document.addEventListener('dragend', e => {
        if (e.target?.classList?.contains('kanban-card')) {
            log('dragend fired, scanning');
            setTimeout(processMoves, 100); // small delay to let Vue patch
        }
    });

    // ---------------------------
    // Init
    // ---------------------------
    (async function init() {
        log('Starting Vikunja AutoLabel script');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view page; aborting.');
            return;
        }

        await refreshMaps();

        mo.observe(document.body, { childList: true, subtree: true });
        log('Observer active — will auto-label moves.');

        // initial scan
        setTimeout(() => { processMoves(); }, 500);
    })();

})();

2 Likes

Great to see that you’re extending Vikunja!

Can you explain your use-case for these? What makes you more productive with these scripts?

A video demonstrating the use would be awesome as well.

1 Like

Quick Filters

fitler

Labels for Buckets

label

1 Like

New feature: Auto label like before and purge archive after n days.

// ==UserScript==
// @name         Vikunja Auto Label by Column + Purge Archive
// @namespace    malys
// @version      3.0
// @description  Auto-apply column labels when a task is moved between Kanban columns in Vikunja — resolves short IDs and label IDs via API
// @match        https://TODO/*
// @match		 https://try.vikunja.io/*	
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ---------------------------
    // CONFIG
    // ---------------------------
    const DEBUG = false;                    // set to false to silence logs
    const TOKEN_KEY = 'token';             // change if your token is stored under a different key in localStorage
    const API_BASE_ORIGIN = window.location.origin; // uses same origin as the app
    const API_BASE = `${API_BASE_ORIGIN}/api/v1`;
    const DEBOUNCE_MS = 250;               // debounce processing after mutations
    const STORAGE_KEY = "kanban_puruge_persistent_v2_origin";  // origin-scoped storage

    const log = (...args) => { if (DEBUG) console.log('[AutoLabel]', ...args); };

    // ---------------------------
    // Auth helpers
    // ---------------------------
    function getToken() {
        return localStorage.getItem(TOKEN_KEY) || '';
    }

    function authHeaders(extra = {}) {
        const token = getToken();
        const base = { ...extra };
        if (token) base['Authorization'] = `Bearer ${token}`;
        if (!base['Accept']) base['Accept'] = 'application/json, text/plain, */*';
        return base;
    }

    // ---------------------------
    // URL helpers (project & view)
    // ---------------------------
    function getProjectAndViewFromUrl() {
        const m = window.location.pathname.match(/\/projects\/(\d+)\/(\d+)/);
        if (!m) return null;
        return { projectId: parseInt(m[1], 10), viewId: parseInt(m[2], 10) };
    }

    // ---------------------------
    // API helpers
    // ---------------------------
    async function fetchJson(url, opts = {}) {
        const res = await fetch(url, { credentials: 'include', ...opts });
        if (!res.ok) {
            const txt = await res.text().catch(() => '');
            throw new Error(`HTTP ${res.status}: ${txt}`);
        }
        return res.json();
    }

    async function loadViewTasks(projectId, viewId) {
        const url = `${API_BASE}/projects/${projectId}/views/${viewId}/tasks?filter=&filter_include_nulls=false&s=&per_page=100&page=1`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getTaskByLabel(projectId, viewId, labelId) {
        const url = `${API_BASE}/projects/${projectId}/tasks?filter=labels+in+${labelId}`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getAllLabels() {
        return await fetchJson(`${API_BASE}/labels`, { headers: authHeaders() });
    }


    async function createLabel(title) {
        return await fetchJson(`${API_BASE}/labels`, {
            method: 'PUT',
            headers: authHeaders({ 'Content-Type': 'application/json' }),
            body: JSON.stringify({ title })
        });
    }

    async function addLabelToTask(taskId, labelId) {
        // Vikunja UI used PUT to /tasks/:id/labels with a body — replicate that
        const url = `${API_BASE}/tasks/${taskId}/labels`;
        const body = JSON.stringify({ max_permission: null, id: 0, task_id: taskId, label_id: labelId });
        const res = await fetch(url, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, credentials: 'include' });
        if (!res.ok) throw new Error(`addLabel failed ${res.status}`);
        return res;
    }

    async function removeLabelFromTask(taskId, labelId) {
        const url = `${API_BASE}/tasks/${taskId}/labels/${labelId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function removeTask(taskId) {
        const url = `${API_BASE}/tasks/${taskId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function getTaskLabels(taskId) {
        return await fetchJson(`${API_BASE}/tasks/${taskId}/labels`, { headers: authHeaders() });
    }

    // ---------------------------
    // Utilities: normalize column/label name
    // ---------------------------
    const normalize = s => (String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ''));

    // ---------------------------
    // Extract short ID from card DOM
    // ---------------------------
    function extractShortIdFromCard(card) {

        try {
            const idNode = card.querySelector('.task-id');
            if (!idNode) return null;
            const text = idNode.textContent || '';
            return text.replace("Done", "");
        } catch (e) {
            return null;
        }
    }

    // ---------------------------
    // Column name from card
    // ---------------------------
    function getColumnFromCard(card) {
        const bucket = card.closest('.bucket');
        if (!bucket) return null;
        const h2 = bucket.querySelector('h2.title');
        if (!h2) return null;
        return h2.textContent.trim();
    }

    // ---------------------------
    // Main state
    // ---------------------------
    const cardColumn = new WeakMap();         // track last known column per card element
    let shortIdToNumeric = {};                // map shortId -> numeric id
    let labelTitleToObj = {};                 // map normalized label title -> label object
    let lastLoadAt = 0;

    // ---------------------------
    // Load view tasks & labels and build maps
    // ---------------------------
    async function refreshMaps() {
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;

        log('Loading view tasks for', projectId, viewId);
        try {
            const viewCols = await loadViewTasks(projectId, viewId);
            shortIdToNumeric = {};
            for (const col of viewCols) {
                if (!col.tasks) continue;
                for (const task of col.tasks) {
                    if (task.identifier) shortIdToNumeric[task.identifier.toUpperCase()] = task.id;
                }
            }
            log('Built shortId->id map', shortIdToNumeric);
        } catch (e) {
            console.error('[AutoLabel] Failed to load view tasks:', e);
        }

        log('Loading labels');
        try {
            const labels = await getAllLabels();
            labelTitleToObj = {};
            for (const l of labels) {
                labelTitleToObj[normalize(l.title)] = l;
            }
            log('Built label map', Object.keys(labelTitleToObj));
        } catch (e) {
            console.error('[AutoLabel] Failed to load labels:', e);
        }

        lastLoadAt = Date.now();
    }

    // ---------------------------
    // Resolve numeric id from short id (uses prebuilt map, or refreshes if missing)
    // ---------------------------
    async function resolveNumericId(shortId) {
        if (!shortId) return null;
        shortId = shortId.toUpperCase();
        if (shortIdToNumeric[shortId]) return shortIdToNumeric[shortId];

        // try refresh once
        await refreshMaps();
        return shortIdToNumeric[shortId] || null;
    }

    // ---------------------------
    // Find or create label object for column name
    // ---------------------------
    async function ensureLabelForColumn(columnName) {
        const key = normalize(columnName);
        let label = labelTitleToObj[key];
        if (label) return label;

        log('Label for column not found, creating:', columnName);
        try {
            label = await createLabel(columnName);
            labelTitleToObj[normalize(label.title)] = label;
            return label;
        } catch (e) {
            console.error('[AutoLabel] Failed to create label:', e);
            return null;
        }
    }

    // ---------------------------
    // Core: handle a card move
    // ---------------------------
    async function handleCardMove(card) {
        try {
            const shortId = extractShortIdFromCard(card);
            if (DEBUG) log('shortId', shortId);
            if (!shortId) return;

            const numericId = await resolveNumericId(shortId);
            if (DEBUG) log('numericId', numericId);
            if (!numericId) {
                log('Could not resolve numeric id for', shortId);
                return;
            }

            const colName = getColumnFromCard(card);
            if (DEBUG) log('colName', colName);
            if (!colName) return;
            const normalizedCol = normalize(colName);

            log(`Task ${shortId} (${numericId}) moved to column '${colName}'`);

            // ensure label exists for this column
            const labelObj = await ensureLabelForColumn(colName);
            if (DEBUG) log('labelObj', labelObj);
            if (!labelObj) return;

            // fetch current labels on task
            let currentLabels = [];
            try {
                currentLabels = await getTaskLabels(numericId);
                if (DEBUG) log('currentLabels', currentLabels);
            } catch (e) {
                console.error('[AutoLabel] Failed to get task labels', e);
            }
            if (currentLabels == null) {
                currentLabels = [];
            }

            // remove labels that don't match column
            for (const old of currentLabels) {
                if (normalize(old.title) !== normalizedCol) {
                    try {
                        log('Removing label', old.title, 'from task', numericId);
                        await removeLabelFromTask(numericId, old.id);
                    } catch (e) {
                        console.error('[AutoLabel] failed remove label', e);
                    }
                }
            }

            // add target label if not present
            const already = currentLabels.some(l => normalize(l.title) === normalizedCol);
            if (!already) {
                try {
                    log('Adding label', labelObj.title, 'to task', numericId);
                    await addLabelToTask(numericId, labelObj.id);
                } catch (e) {
                    console.error('[AutoLabel] failed add label', e);
                }
            } else {
                log('Task already has target label');
            }
        } catch (e) {
            console.error('[AutoLabel] handleCardMove exception', e);
        }
    }

    // ---------------------------
    // Process DOM: scan cards and detect column changes
    // ---------------------------
    async function processMoves() {
        // refresh maps periodically (every 60s) to stay in sync
        if (Date.now() - lastLoadAt > 60_000) await refreshMaps();

        document.querySelectorAll('.kanban-card').forEach(card => {
            const col = getColumnFromCard(card);
            if (!col) return;
            const prev = cardColumn.get(card);
            if (prev === col) return;
            cardColumn.set(card, col);
            handleCardMove(card);
        });
    }

    // ---------------------------
    // MutationObserver + debounce
    // ---------------------------
    let debounceTimer = null;
    const mo = new MutationObserver((mutations) => {
        if (DEBUG) log('Mutations', mutations.map(m => ({ type: m.type, added: m.addedNodes.length, removed: m.removedNodes.length })));
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => { Promise.resolve().then(processMoves); }, DEBOUNCE_MS);
    });

    // Extra: listen to dragend to force-scan
    document.addEventListener('dragend', e => {
        if (e.target?.classList?.contains('kanban-card')) {
            log('dragend fired, scanning');
            setTimeout(processMoves, 100); // small delay to let Vue patch
        }
    });

    // ---------------------------
    // Init
    // ---------------------------
    (async function init() {
        log('Starting Vikunja AutoLabel script');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view page; aborting.');
            return;
        }

        await refreshMaps();

        mo.observe(document.body, { childList: true, subtree: true });
        log('Observer active — will auto-label moves.');

        // initial scan
        setTimeout(() => { processMoves(); }, 500);

        // Run when kanban loads
        waitForKanban().then(injectCleanupUI);
    })();

    function waitForKanban() {
        return new Promise(resolve => {
            const check = () => {
                if (document.querySelector(".kanban-view")) resolve();
                else setTimeout(check, 300);
            };
            check();
        });
    }

    /* =====================================================================
       CLEANUP PERSISTENT SETTINGS UI
       ===================================================================== */

    const CLEAN_STORAGE_KEY = "kanban_cleanup_persistent_v1_origin";

    function loadCleanupConfig() {
        try {
            const obj = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
            return obj[window.location.origin] || { days: 30 };
        } catch (e) {
            return { days: 30 };
        }
    }

    function saveCleanupConfig(cfg) {
        let all = {};
        try {
            all = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
        } catch (e) { }
        all[window.location.origin] = cfg;
        localStorage.setItem(CLEAN_STORAGE_KEY, JSON.stringify(all));
    }

    /********************************************************************
      *  UI: SINGLE BUTTON + SLIDE PANEL
      ********************************************************************/
    function injectCleanupUI() {
        if (document.getElementById("cleanupSidebar")) return;

        const DARK = window.matchMedia("(prefers-color-scheme: dark)").matches;
        const C = DARK
            ? {
                panel: "#262626",
                border: "#333",
                text: "#eee",
                button: "#3a6ee8",
                bg: "#1f1f1f",
                shadow: "rgba(0,0,0,0.6)",
            }
            : {
                panel: "#f7f7f7",
                border: "#ccc",
                text: "#333",
                button: "#4a90e2",
                bg: "#fff",
                shadow: "rgba(0,0,0,0.25)",
            };

        /***********************
         * Button
         ************************/
        const button = document.createElement("div");
        button.id = "cleanupMainBtn";
        button.textContent = "🧹 Clean Archived";
        button.style.cssText = `
            position: fixed;
            bottom: 70px;
            right: 20px;
            background: ${C.button};
            color: white;
            padding: 11px 16px;
            border-radius: 30px;
            cursor: pointer;
            font-size: 14px;
            z-index: 999999;
            box-shadow: 0 4px 12px ${C.shadow};
            user-select: none;
        `;
        document.body.appendChild(button);

        /***********************
         * Slide panel
         ************************/
        const panel = document.createElement("div");
        panel.id = "cleanupSidebar";
        panel.style.cssText = `
            position: fixed;
            top: 0;
            right: -350px;
            width: 330px;
            height: 100%;
            background: ${C.panel};
            color: ${C.text};
            border-left: 1px solid ${C.border};
            box-shadow: -4px 0 20px ${C.shadow};
            padding: 16px;
            box-sizing: border-box;
            z-index: 999998;
            transition: right .25s ease;
            font-family: sans-serif;
        `;

        const cfg = loadCleanupConfig();

        panel.innerHTML = `
            <h3 style="margin: 0 0 12px;">Clean Archived</h3>

            <label style="font-size: 14px;">Delete archived tasks older than (days):</label>
            <input id="cleanupDaysInput" type="number" min="1"
                style="width: 100%; margin: 8px 0 20px; padding: 8px;
                border: 1px solid ${C.border};
                border-radius: 6px; background: ${C.bg};
                color: ${C.text};"
            >

            <button id="cleanupSaveBtn" style="
                width:100%; padding:10px; background:${C.button};
                color:white; border:none; border-radius:6px;
                cursor:pointer; margin-bottom:16px;">
                Save Settings
            </button>

            <button id="cleanupRunBtn" style="
                width:100%; padding:12px; background:crimson;
                color:white; border:none; border-radius:6px;
                cursor:pointer; font-size:15px; font-weight:bold;">
                🧨 Run Clean Now
            </button>

            <div style="margin-top:14px; font-size:12px; opacity:.7;">
                Settings stored for this domain.<br>
                “Run Clean Now” applies the current values.
            </div>
        `;
        document.body.appendChild(panel);

        /***********************
         * Events
         ************************/
        const input = panel.querySelector("#cleanupDaysInput");
        const saveBtn = panel.querySelector("#cleanupSaveBtn");
        const runBtn = panel.querySelector("#cleanupRunBtn");
        input.value = cfg.days;

        button.onclick = () => {
            panel.style.right = "0";
        };

        saveBtn.onclick = () => {
            const days = Number(input.value);
            if (!days || days < 1) {
                alert("Invalid days.");
                return;
            }
            saveCleanupConfig({ days });
            alert("Saved ✔");
        };

        runBtn.onclick = () => {
            panel.style.right = "-350px";
            bulkCleanArchived();
        };

        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape") panel.style.right = "-350px";
        });
    }

    async function bulkCleanArchived() {
        // Load existing config
        const cfg = loadCleanupConfig();

        if (DEBUG) log('bulkCleanArchive start');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;
        if (DEBUG) log('bulkCleanArchive projectId', projectId, 'viewId', viewId);

        const labels = await getAllLabels();
        const labelId = labels.find(l => l.title.toLowerCase() === "archive").id;
        if (!labelId) {
            log('No archive label found');
            return;
        }
        if (DEBUG) log('bulkCleanArchive labelId', labelId);

        const tasks = await getTaskByLabel(projectId, viewId, labelId)
        if (tasks.length === 0) {
            alert("No tasks in Archive.");
            return;
        }
        if (DEBUG) log('bulkCleanArchive tasks', tasks.length);

        const oldTasks = [];
        const oneMonthAgo = Date.now() - cfg.days * 24 * 60 * 60 * 1000;
        const delayLabel = cfg.days + " day(s)";

        for (const t of tasks) {
            const updated = new Date(t.updated).getTime();
            if (DEBUG) log('bulkCleanArchive task', t.id, updated);

            if (updated < oneMonthAgo) {
                oldTasks.push({
                    id: t.id,
                    shortid: t.identifier,
                    title: t.title,
                    updated: t.updated
                });
            }
        }

        if (oldTasks.length === 0) {
            alert(`No archived tasks older than ${delayLabel}.`);
            return;
        }

        const list = oldTasks
            .map(t => `• [${t.shortid}] ${t.title} (updated: ${t.updated})`)
            .join("\n");

        const ok = confirm(
            `Delete the following ${oldTasks.length} archived tasks?\n\n${list}\n\nThis cannot be undone.`
        );
        if (!ok) return;

        for (const t of oldTasks) {
            if (DEBUG) log('bulkCleanArchive delete', t.title);
            await removeTask(t.id)
        }

        alert(`Deleted ${oldTasks.length} old archived tasks.\nReloading...`);
        location.reload();
    }

})();

This script is not mine but I share it:

Fix: remove labels from bucket name only

// ==UserScript==
// @name         Vikunja Auto Label by Column + Purge Archive
// @namespace    malys
// @version      3.0
// @description  Auto-apply column labels when a task is moved between Kanban columns in Vikunja — resolves short IDs and label IDs via API
// @match        https://TODO/*
// @match		 https://try.vikunja.io/*	
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ---------------------------
    // CONFIG
    // ---------------------------
    const DEBUG = false;                    // set to false to silence logs
    const TOKEN_KEY = 'token';             // change if your token is stored under a different key in localStorage
    const API_BASE_ORIGIN = window.location.origin; // uses same origin as the app
    const API_BASE = `${API_BASE_ORIGIN}/api/v1`;
    const DEBOUNCE_MS = 250;               // debounce processing after mutations
    const STORAGE_KEY = "kanban_puruge_persistent_v2_origin";  // origin-scoped storage

    const log = (...args) => { if (DEBUG) console.log('[AutoLabel]', ...args); };

    // ---------------------------
    // Auth helpers
    // ---------------------------
    function getToken() {
        return localStorage.getItem(TOKEN_KEY) || '';
    }

    function authHeaders(extra = {}) {
        const token = getToken();
        const base = { ...extra };
        if (token) base['Authorization'] = `Bearer ${token}`;
        if (!base['Accept']) base['Accept'] = 'application/json, text/plain, */*';
        return base;
    }

    // ---------------------------
    // URL helpers (project & view)
    // ---------------------------
    function getProjectAndViewFromUrl() {
        const m = window.location.pathname.match(/\/projects\/(\d+)\/(\d+)/);
        if (!m) return null;
        return { projectId: parseInt(m[1], 10), viewId: parseInt(m[2], 10) };
    }

    // ---------------------------
    // API helpers
    // ---------------------------
    async function fetchJson(url, opts = {}) {
        const res = await fetch(url, { credentials: 'include', ...opts });
        if (!res.ok) {
            const txt = await res.text().catch(() => '');
            throw new Error(`HTTP ${res.status}: ${txt}`);
        }
        return res.json();
    }

    async function loadViewTasks(projectId, viewId) {
        const url = `${API_BASE}/projects/${projectId}/views/${viewId}/tasks?filter=&filter_include_nulls=false&s=&per_page=100&page=1`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getTaskByLabel(projectId, viewId, labelId) {
        const url = `${API_BASE}/projects/${projectId}/tasks?filter=labels+in+${labelId}`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getAllLabels() {
        return await fetchJson(`${API_BASE}/labels`, { headers: authHeaders() });
    }


    async function createLabel(title) {
        return await fetchJson(`${API_BASE}/labels`, {
            method: 'PUT',
            headers: authHeaders({ 'Content-Type': 'application/json' }),
            body: JSON.stringify({ title })
        });
    }

    async function addLabelToTask(taskId, labelId) {
        // Vikunja UI used PUT to /tasks/:id/labels with a body — replicate that
        const url = `${API_BASE}/tasks/${taskId}/labels`;
        const body = JSON.stringify({ max_permission: null, id: 0, task_id: taskId, label_id: labelId });
        const res = await fetch(url, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, credentials: 'include' });
        if (!res.ok) throw new Error(`addLabel failed ${res.status}`);
        return res;
    }

    async function removeLabelFromTask(taskId, labelId) {
        const url = `${API_BASE}/tasks/${taskId}/labels/${labelId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function removeTask(taskId) {
        const url = `${API_BASE}/tasks/${taskId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function getTaskLabels(taskId) {
        return await fetchJson(`${API_BASE}/tasks/${taskId}/labels`, { headers: authHeaders() });
    }

    // ---------------------------
    // Utilities: normalize column/label name
    // ---------------------------
    const normalize = s => (String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ''));

    // ---------------------------
    // Extract short ID from card DOM
    // ---------------------------
    function extractShortIdFromCard(card) {

        try {
            const idNode = card.querySelector('.task-id');
            if (!idNode) return null;
            const text = idNode.textContent || '';
            return text.replace("Done", "");
        } catch (e) {
            return null;
        }
    }

    // ---------------------------
    // Column name from card
    // ---------------------------
    function getColumnFromCard(card) {
        const bucket = card.closest('.bucket');
        if (!bucket) return null;
        const h2 = bucket.querySelector('h2.title');
        if (!h2) return null;
        return h2.textContent.trim();
    }

    function getAllColumnNames() {
        return Array.from(document.querySelectorAll('.bucket h2.title'))
            .map(h2 => (h2.textContent || '').trim())
            .filter(Boolean);
    }

    // ---------------------------
    // Main state
    // ---------------------------
    const cardColumn = new WeakMap();         // track last known column per card element
    let shortIdToNumeric = {};                // map shortId -> numeric id
    let labelTitleToObj = {};                 // map normalized label title -> label object
    let lastLoadAt = 0;

    // ---------------------------
    // Load view tasks & labels and build maps
    // ---------------------------
    async function refreshMaps() {
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;

        log('Loading view tasks for', projectId, viewId);
        try {
            const viewCols = await loadViewTasks(projectId, viewId);
            shortIdToNumeric = {};
            for (const col of viewCols) {
                if (!col.tasks) continue;
                for (const task of col.tasks) {
                    if (task.identifier) shortIdToNumeric[task.identifier.toUpperCase()] = task.id;
                }
            }
            log('Built shortId->id map', shortIdToNumeric);
        } catch (e) {
            console.error('[AutoLabel] Failed to load view tasks:', e);
        }

        log('Loading labels');
        try {
            const labels = await getAllLabels();
            labelTitleToObj = {};
            for (const l of labels) {
                labelTitleToObj[normalize(l.title)] = l;
            }
            log('Built label map', Object.keys(labelTitleToObj));
        } catch (e) {
            console.error('[AutoLabel] Failed to load labels:', e);
        }

        lastLoadAt = Date.now();
    }

    // ---------------------------
    // Resolve numeric id from short id (uses prebuilt map, or refreshes if missing)
    // ---------------------------
    async function resolveNumericId(shortId) {
        if (!shortId) return null;
        shortId = shortId.toUpperCase();
        if (shortIdToNumeric[shortId]) return shortIdToNumeric[shortId];

        // try refresh once
        await refreshMaps();
        return shortIdToNumeric[shortId] || null;
    }

    // ---------------------------
    // Find or create label object for column name
    // ---------------------------
    async function ensureLabelForColumn(columnName) {
        const key = normalize(columnName);
        let label = labelTitleToObj[key];
        if (label) return label;

        log('Label for column not found, creating:', columnName);
        try {
            label = await createLabel(columnName);
            labelTitleToObj[normalize(label.title)] = label;
            return label;
        } catch (e) {
            console.error('[AutoLabel] Failed to create label:', e);
            return null;
        }
    }

    // ---------------------------
    // Core: handle a card move
    // ---------------------------
    async function handleCardMove(card) {
        try {
            const shortId = extractShortIdFromCard(card);
            if (DEBUG) log('shortId', shortId);
            if (!shortId) return;

            const numericId = await resolveNumericId(shortId);
            if (DEBUG) log('numericId', numericId);
            if (!numericId) {
                log('Could not resolve numeric id for', shortId);
                return;
            }

            const colName = getColumnFromCard(card);
            if (DEBUG) log('colName', colName);
            if (!colName) return;
            const normalizedCol = normalize(colName);

            log(`Task ${shortId} (${numericId}) moved to column '${colName}'`);

            // ensure label exists for this column
            const labelObj = await ensureLabelForColumn(colName);
            if (DEBUG) log('labelObj', labelObj);
            if (!labelObj) return;

            // fetch current labels on task
            let currentLabels = [];
            try {
                currentLabels = await getTaskLabels(numericId);
                if (DEBUG) log('currentLabels', currentLabels);
            } catch (e) {
                console.error('[AutoLabel] Failed to get task labels', e);
            }
            if (currentLabels == null) {
                currentLabels = [];
            }

            // remove labels that correspond to other bucket names (allow non-column labels)
            const bucketNameSet = new Set(getAllColumnNames().map(normalize));
            for (const old of currentLabels) {
                const normalizedOld = normalize(old.title);
                if (bucketNameSet.has(normalizedOld) && normalizedOld !== normalizedCol) {
                    try {
                        log('Removing label', old.title, 'from task', numericId);
                        await removeLabelFromTask(numericId, old.id);
                    } catch (e) {
                        console.error('[AutoLabel] failed remove label', e);
                    }
                }
            }

            // add target label if not present
            const already = currentLabels.some(l => normalize(l.title) === normalizedCol);
            if (!already) {
                try {
                    log('Adding label', labelObj.title, 'to task', numericId);
                    await addLabelToTask(numericId, labelObj.id);
                } catch (e) {
                    console.error('[AutoLabel] failed add label', e);
                }
            } else {
                log('Task already has target label');
            }
        } catch (e) {
            console.error('[AutoLabel] handleCardMove exception', e);
        }
    }

    // ---------------------------
    // Process DOM: scan cards and detect column changes
    // ---------------------------
    async function processMoves() {
        // refresh maps periodically (every 60s) to stay in sync
        if (Date.now() - lastLoadAt > 60_000) await refreshMaps();

        document.querySelectorAll('.kanban-card').forEach(card => {
            const col = getColumnFromCard(card);
            if (!col) return;
            const prev = cardColumn.get(card);
            if (prev === col) return;
            cardColumn.set(card, col);
            handleCardMove(card);
        });
    }

    // ---------------------------
    // MutationObserver + debounce
    // ---------------------------
    let debounceTimer = null;
    const mo = new MutationObserver((mutations) => {
        if (DEBUG) log('Mutations', mutations.map(m => ({ type: m.type, added: m.addedNodes.length, removed: m.removedNodes.length })));
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => { Promise.resolve().then(processMoves); }, DEBOUNCE_MS);
    });

    // Extra: listen to dragend to force-scan
    document.addEventListener('dragend', e => {
        if (e.target?.classList?.contains('kanban-card')) {
            log('dragend fired, scanning');
            setTimeout(processMoves, 100); // small delay to let Vue patch
        }
    });

    // ---------------------------
    // Init
    // ---------------------------
    (async function init() {
        log('Starting Vikunja AutoLabel script');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view page; aborting.');
            return;
        }

        await refreshMaps();

        mo.observe(document.body, { childList: true, subtree: true });
        log('Observer active — will auto-label moves.');

        // initial scan
        setTimeout(() => { processMoves(); }, 500);

        // Run when kanban loads
        waitForKanban().then(injectCleanupUI);
    })();

    function waitForKanban() {
        return new Promise(resolve => {
            const check = () => {
                if (document.querySelector(".kanban-view")) resolve();
                else setTimeout(check, 300);
            };
            check();
        });
    }

    /* =====================================================================
       CLEANUP PERSISTENT SETTINGS UI
       ===================================================================== */

    const CLEAN_STORAGE_KEY = "kanban_cleanup_persistent_v1_origin";

    function loadCleanupConfig() {
        try {
            const obj = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
            return obj[window.location.origin] || { days: 30 };
        } catch (e) {
            return { days: 30 };
        }
    }

    function saveCleanupConfig(cfg) {
        let all = {};
        try {
            all = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
        } catch (e) { }
        all[window.location.origin] = cfg;
        localStorage.setItem(CLEAN_STORAGE_KEY, JSON.stringify(all));
    }

    /********************************************************************
      *  UI: SINGLE BUTTON + SLIDE PANEL
      ********************************************************************/
    function injectCleanupUI() {
        if (document.getElementById("cleanupSidebar")) return;

        const DARK = window.matchMedia("(prefers-color-scheme: dark)").matches;
        const C = DARK
            ? {
                panel: "#262626",
                border: "#333",
                text: "#eee",
                button: "#3a6ee8",
                bg: "#1f1f1f",
                shadow: "rgba(0,0,0,0.6)",
            }
            : {
                panel: "#f7f7f7",
                border: "#ccc",
                text: "#333",
                button: "#4a90e2",
                bg: "#fff",
                shadow: "rgba(0,0,0,0.25)",
            };

        /***********************
         * Button
         ************************/
        const button = document.createElement("div");
        button.id = "cleanupMainBtn";
        button.textContent = "🧹 Clean Archived";
        button.style.cssText = `
            position: fixed;
            bottom: 70px;
            right: 20px;
            background: ${C.button};
            color: white;
            padding: 11px 16px;
            border-radius: 30px;
            cursor: pointer;
            font-size: 14px;
            z-index: 999999;
            box-shadow: 0 4px 12px ${C.shadow};
            user-select: none;
        `;
        document.body.appendChild(button);

        /***********************
         * Slide panel
         ************************/
        const panel = document.createElement("div");
        panel.id = "cleanupSidebar";
        panel.style.cssText = `
            position: fixed;
            top: 0;
            right: -350px;
            width: 330px;
            height: 100%;
            background: ${C.panel};
            color: ${C.text};
            border-left: 1px solid ${C.border};
            box-shadow: -4px 0 20px ${C.shadow};
            padding: 16px;
            box-sizing: border-box;
            z-index: 999998;
            transition: right .25s ease;
            font-family: sans-serif;
        `;

        const cfg = loadCleanupConfig();

        panel.innerHTML = `
            <h3 style="margin: 0 0 12px;">Clean Archived</h3>

            <label style="font-size: 14px;">Delete archived tasks older than (days):</label>
            <input id="cleanupDaysInput" type="number" min="1"
                style="width: 100%; margin: 8px 0 20px; padding: 8px;
                border: 1px solid ${C.border};
                border-radius: 6px; background: ${C.bg};
                color: ${C.text};"
            >

            <button id="cleanupSaveBtn" style="
                width:100%; padding:10px; background:${C.button};
                color:white; border:none; border-radius:6px;
                cursor:pointer; margin-bottom:16px;">
                Save Settings
            </button>

            <button id="cleanupRunBtn" style="
                width:100%; padding:12px; background:crimson;
                color:white; border:none; border-radius:6px;
                cursor:pointer; font-size:15px; font-weight:bold;">
                🧨 Run Clean Now
            </button>

            <div style="margin-top:14px; font-size:12px; opacity:.7;">
                Settings stored for this domain.<br>
                “Run Clean Now” applies the current values.
            </div>
        `;
        document.body.appendChild(panel);

        /***********************
         * Events
         ************************/
        const input = panel.querySelector("#cleanupDaysInput");
        const saveBtn = panel.querySelector("#cleanupSaveBtn");
        const runBtn = panel.querySelector("#cleanupRunBtn");
        input.value = cfg.days;

        button.onclick = () => {
            panel.style.right = "0";
        };

        saveBtn.onclick = () => {
            const days = Number(input.value);
            if (!days || days < 1) {
                alert("Invalid days.");
                return;
            }
            saveCleanupConfig({ days });
            alert("Saved ✔");
        };

        runBtn.onclick = () => {
            panel.style.right = "-350px";
            bulkCleanArchived();
        };

        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape") panel.style.right = "-350px";
        });
    }

    async function bulkCleanArchived() {
        // Load existing config
        const cfg = loadCleanupConfig();

        if (DEBUG) log('bulkCleanArchive start');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;
        if (DEBUG) log('bulkCleanArchive projectId', projectId, 'viewId', viewId);

        const labels = await getAllLabels();
        const labelId = labels.find(l => l.title.toLowerCase() === "archive").id;
        if (!labelId) {
            log('No archive label found');
            return;
        }
        if (DEBUG) log('bulkCleanArchive labelId', labelId);

        const tasks = await getTaskByLabel(projectId, viewId, labelId)
        if (tasks.length === 0) {
            alert("No tasks in Archive.");
            return;
        }
        if (DEBUG) log('bulkCleanArchive tasks', tasks.length);

        const oldTasks = [];
        const oneMonthAgo = Date.now() - cfg.days * 24 * 60 * 60 * 1000;
        const delayLabel = cfg.days + " day(s)";

        for (const t of tasks) {
            const updated = new Date(t.updated).getTime();
            if (DEBUG) log('bulkCleanArchive task', t.id, updated);

            if (updated < oneMonthAgo) {
                oldTasks.push({
                    id: t.id,
                    shortid: t.identifier,
                    title: t.title,
                    updated: t.updated
                });
            }
        }

        if (oldTasks.length === 0) {
            alert(`No archived tasks older than ${delayLabel}.`);
            return;
        }

        const list = oldTasks
            .map(t => `• [${t.shortid}] ${t.title} (updated: ${t.updated})`)
            .join("\n");

        const ok = confirm(
            `Delete the following ${oldTasks.length} archived tasks?\n\n${list}\n\nThis cannot be undone.`
        );
        if (!ok) return;

        for (const t of oldTasks) {
            if (DEBUG) log('bulkCleanArchive delete', t.title);
            await removeTask(t.id)
        }

        alert(`Deleted ${oldTasks.length} old archived tasks.\nReloading...`);
        location.reload();
    }

})();



Use ctrl+/ instead /

// ==UserScript==
// @name         Vikunja filter (origin storage + prompt apply)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Press CTRL+"/" to filter tasks live. Save/load small filter presets (only the `filter=` param). Export/import presets. Prompts Replace/Merge when applying. Origin-scoped storage.
// @match        https://todo/*
// @match		 https://try.vikunja.io/*	
// @grant        none
// ==/UserScript==

(function() {
    "use strict";

    /* -------------------------------------------------------
       SETTINGS
    --------------------------------------------------------- */
    const STORAGE_KEY = "kanban_filters_persistent_v2_origin";  // origin-scoped storage
    const AUTO_DARK = true;
    const FORCE_DARK = false;

    function isDarkMode() {
        if (FORCE_DARK) return true;
        if (!AUTO_DARK) return false;
        return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
    }
    const DARK = isDarkMode();

    const colors = DARK ? {
        bg: "#1f1f1f",
        panel: "#262626",
        border: "#333",
        text: "#eee",
        button: "#3a6ee8",
        shadow: "rgba(0,0,0,0.6)"
    } : {
        bg: "#fff",
        panel: "#f7f7f7",
        border: "#ccc",
        text: "#333",
        button: "#4a90e2",
        shadow: "rgba(0,0,0,0.25)"
    };

    /* -------------------------------------------------------
       STORAGE HELPERS (origin-scoped)
    --------------------------------------------------------- */
    function loadFilters() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
        } catch (e) {
            console.warn("Failed to parse saved filters, resetting.", e);
            return {};
        }
    }

    function saveFilters(obj) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
    }

    function getBaseKey() {
        return window.location.origin; // <-- origin-scoped
    }

    /* -------------------------------------------------------
       UI: Floating button + sidebar
    --------------------------------------------------------- */
    const floatingBtn = document.createElement("div");
    floatingBtn.textContent = "⚙️ Filters";
    floatingBtn.style.cssText = `
        position: fixed; bottom: 20px; right: 20px;
        background: ${colors.button}; color: white;
        padding: 10px 14px; border-radius: 30px; cursor: pointer;
        font-size: 14px; z-index: 999999; box-shadow: 0 4px 12px ${colors.shadow};
        user-select: none;
    `;
    document.body.appendChild(floatingBtn);

    const sidebar = document.createElement("div");
    sidebar.style.cssText = `
        position: fixed; top: 0; right: -380px; width: 360px; height: 100%;
        background: ${colors.panel}; color: ${colors.text}; border-left: 1px solid ${colors.border};
        box-shadow: -4px 0 20px ${colors.shadow}; z-index: 999998; transition: right 0.25s ease;
        padding: 14px; font-family: sans-serif; box-sizing: border-box;
    `;
    sidebar.innerHTML = `
        <h3 style="margin:0 0 12px 0;">Saved Filters</h3>
        <div style="display:flex; gap:8px; margin-bottom:12px;">
            <button id="addFilterBtn" style="
                flex:1; padding:8px 0; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">+ Add Current Filter</button>
            <button id="exportFiltersBtn" style="
                padding:8px 10px; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">⬇️</button>
            <button id="importFiltersBtn" style="
                padding:8px 10px; background:${colors.button}; color:white;
                border:none; border-radius:6px; cursor:pointer; font-size:13px;
            ">⬆️</button>
        </div>
        <input type="file" id="filtersFileInput" style="display:none" accept="application/json">
        <div id="savedFilters" style="max-height:78%; overflow-y:auto;"></div>
        <div style="opacity:.8; font-size:12px; margin-top:10px;">
            <div>Click title to apply (Replace). When applying you will be asked Replace / Merge / Cancel.</div>
            <div>Saved value contains only the <code>filter</code> parameter.</div>
        </div>
    `;
    document.body.appendChild(sidebar);

    const savedFiltersDiv = sidebar.querySelector("#savedFilters");
    const addFilterBtn = sidebar.querySelector("#addFilterBtn");
    const exportFiltersBtn = sidebar.querySelector("#exportFiltersBtn");
    const importFiltersBtn = sidebar.querySelector("#importFiltersBtn");
    const filtersFileInput = sidebar.querySelector("#filtersFileInput");

    function openSidebar() {
        sidebar.style.right = "0";
        renderFilters();
    }
    function closeSidebar() {
        sidebar.style.right = "-380px";
    }
    floatingBtn.onclick = openSidebar;
    document.addEventListener("keydown", e => {
        if (e.key === "Escape") {
            if (sidebar.style.right === "0px" || sidebar.style.right === "0") closeSidebar();
            else resetSearch();
        }
    });

    /* -------------------------------------------------------
       Utility: merge two filter strings (comma-separated),
       avoid duplicates and empty parts, preserve order.
    --------------------------------------------------------- */
    function mergeFilterStrings(a, b) {
        // split by comma, trim, filter out empty, keep unique while preserving order
        const parts = [];
        function pushParts(s) {
            if (!s) return;
            s.split(",").forEach(p => {
                const t = p.trim();
                if (!t) return;
                if (!parts.includes(t)) parts.push(t);
            });
        }
        pushParts(a);
        pushParts(b);
        return parts.join(" && ");
    }

    /* -------------------------------------------------------
       APPLY FILTER — prompts user to Replace / Merge / Cancel
    --------------------------------------------------------- */
    function applyFilterWithPrompt(savedEntry) {
        // extract saved filter (backwards compat if older .url exists)
        let saved = typeof savedEntry.filter === "string" ? savedEntry.filter : "";
        if ((!saved || saved === "") && savedEntry.url) {
            try {
                const old = new URL(savedEntry.url, window.location.origin);
                saved = old.searchParams.get("filter") || "";
            } catch (err) {
                saved = "";
            }
        }

        // Ask user how to apply
        const choice = window.prompt(
`Apply filter "${savedEntry.title || "(untitled)"}":
R = Replace (set filter=...),
M = Merge (append, avoid duplicates),
C = Cancel
Type R / M / C (default R)`,
"R"
        );

        if (choice === null) return; // cancelled prompt (treat as Cancel)

        const normalized = String(choice).trim().toUpperCase();
        if (normalized === "C") return;

        const url = new URL(window.location.href);

        if (normalized === "M") {
            const current = url.searchParams.get("filter") || "";
            const merged = mergeFilterStrings(current, saved);
            if (merged === "") url.searchParams.delete("filter");
            else url.searchParams.set("filter", merged);
        } else {
            // default Replace
            if ((saved || "").trim() === "") url.searchParams.delete("filter");
            else url.searchParams.set("filter", saved.trim());
        }

        window.location.href = url.toString();
    }

    /* -------------------------------------------------------
       Render saved filters (origin scoped)
    --------------------------------------------------------- */
    function buildBtnStyle() {
        return `
            padding:6px; cursor:pointer;
            background:${colors.panel}; color:${colors.text};
            border:1px solid ${colors.border}; border-radius:6px;
            min-width:40px; font-size:13px;
        `;
    }

    function renderFilters() {
        const all = loadFilters();
        const base = getBaseKey();
        const list = all[base] || [];
        savedFiltersDiv.innerHTML = "";

        if (!Array.isArray(list) || list.length === 0) {
            savedFiltersDiv.innerHTML = `<div style="opacity:.6;">No saved filters</div>`;
            return;
        }

        list.forEach((f, i) => {
            const row = document.createElement("div");
            row.style.cssText = `
                display:flex; flex-direction:column; background:${colors.bg}; color:${colors.text};
                padding:8px; border:1px solid ${colors.border}; border-radius:6px; margin-bottom:10px; box-sizing:border-box;
            `;

            const title = document.createElement("div");
            title.textContent = f.title || "(untitled)";
            title.style.cssText = `cursor:pointer; font-size:15px; font-weight:600; margin-bottom:6px;`;
            title.onclick = () => applyFilterWithPrompt(f);
            row.appendChild(title);

            const filterText = document.createElement("div");
            filterText.textContent = (typeof f.filter === "string" && f.filter !== "") ? f.filter : "(empty filter)";
            filterText.style.cssText = `font-size:13px; opacity:0.88; margin-bottom:8px; word-break:break-all;`;
            row.appendChild(filterText);

            const btnRow = document.createElement("div");
            btnRow.style.cssText = `display:flex; gap:6px;`;

            const editBtn = document.createElement("button");
            editBtn.textContent = "✏️";
            editBtn.title = "Edit title and filter";
            editBtn.style.cssText = buildBtnStyle();
            editBtn.onclick = () => {
                const newTitle = prompt("New title:", f.title || "");
                if (newTitle !== null) f.title = newTitle;
                const newFilter = prompt("New filter (value of `filter=`):", f.filter || "");
                if (newFilter !== null) f.filter = newFilter;

                const allData = loadFilters();
                allData[getBaseKey()] = allData[getBaseKey()] || [];
                allData[getBaseKey()][i] = f;
                saveFilters(allData);
                renderFilters();
            };

            const copyBtn = document.createElement("button");
            copyBtn.textContent = "📋";
            copyBtn.title = "Copy filter value to clipboard";
            copyBtn.style.cssText = buildBtnStyle();
            copyBtn.onclick = async () => {
                try {
                    await navigator.clipboard.writeText(f.filter || "");
                    copyBtn.textContent = "✅";
                    setTimeout(() => (copyBtn.textContent = "📋"), 900);
                } catch (err) {
                    alert("Copy failed. Select and copy: " + (f.filter || ""));
                }
            };

            const mergeBtn = document.createElement("button");
            mergeBtn.textContent = "🔀";
            mergeBtn.title = "Merge with current filter (no prompt: merge)";
            mergeBtn.style.cssText = buildBtnStyle();
            mergeBtn.onclick = () => {
                // immediate merge action
                const url = new URL(window.location.href);
                const current = url.searchParams.get("filter") || "";
                const merged = mergeFilterStrings(current, f.filter || "");
                if (merged === "") url.searchParams.delete("filter");
                else url.searchParams.set("filter", merged);
                window.location.href = url.toString();
            };

            const delBtn = document.createElement("button");
            delBtn.textContent = "❌";
            delBtn.title = "Delete saved filter";
            delBtn.style.cssText = buildBtnStyle();
            delBtn.onclick = () => {
                if (!confirm(`Delete "${f.title}"?`)) return;
                const allData = loadFilters();
                const baseKey = getBaseKey();
                allData[baseKey] = allData[baseKey] || [];
                allData[baseKey].splice(i, 1);
                saveFilters(allData);
                renderFilters();
            };

            btnRow.appendChild(editBtn);
            btnRow.appendChild(copyBtn);
            btnRow.appendChild(mergeBtn);
            btnRow.appendChild(delBtn);
            row.appendChild(btnRow);
            savedFiltersDiv.appendChild(row);
        });
    }

    /* -------------------------------------------------------
       Add current filter (saves only filter param) — origin-scoped
    --------------------------------------------------------- */
    addFilterBtn.onclick = () => {
        const title = prompt("Filter title?");
        if (title === null) return;

        const base = getBaseKey();
        const all = loadFilters();
        if (!all[base]) all[base] = [];

        const params = new URLSearchParams(window.location.search);
        const filterValue = params.get("filter") || "";

        all[base].push({
            title,
            filter: filterValue
        });

        saveFilters(all);
        renderFilters();
    };

    /* -------------------------------------------------------
       Export / Import (JSON format)
    --------------------------------------------------------- */
    exportFiltersBtn.onclick = () => {
        const data = JSON.stringify(loadFilters(), null, 2);
        const blob = new Blob([data], { type: "application/json" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = "vikunja-filters.json";
        a.click();
        URL.revokeObjectURL(url);
    };

    importFiltersBtn.onclick = () => filtersFileInput.click();
    filtersFileInput.onchange = async () => {
        const file = filtersFileInput.files[0];
        if (!file) return;
        try {
            const text = await file.text();
            const json = JSON.parse(text);
            if (typeof json !== "object" || json === null) throw new Error("Invalid format");
            saveFilters(json);
            renderFilters();
            alert("Filters imported!");
        } catch (err) {
            console.error(err);
            alert("Invalid JSON file.");
        } finally {
            filtersFileInput.value = "";
        }
    };

    /* -------------------------------------------------------
       "/" live search (preserved)
    --------------------------------------------------------- */
    const searchInput = document.createElement("input");
    searchInput.placeholder = "Filter tasks…";
    searchInput.style.cssText = `
        position: fixed; top: 10px; left: 50%; transform: translateX(-50%);
        width: 320px; padding: 8px 12px; font-size: 16px; z-index: 999999;
        display:none; background: ${colors.bg}; color: ${colors.text};
        border: 2px solid ${colors.button}; border-radius: 6px; box-shadow: 0 4px 20px ${colors.shadow};
        box-sizing: border-box;
    `;
    document.body.appendChild(searchInput);

    const TASK_SELECTORS = ["li", ".task", ".item", "[data-task]"];
    function getTasks() {
        return Array.from(document.querySelectorAll(TASK_SELECTORS.join(",")))
            .filter(el => el.innerText.trim().length > 0);
    }
    function filterTasks(q) {
        q = q.toLowerCase();
        getTasks().forEach(t => {
            t.style.display = t.innerText.toLowerCase().includes(q) ? "" : "none";
        });
    }
    function resetSearch() {
        searchInput.value = "";
        searchInput.style.display = "none";
        filterTasks("");
    }

    document.addEventListener("keydown", e => {
        if (e.key === "/" && e.ctrlKey && document.activeElement !== searchInput && !e.metaKey) {
            e.preventDefault();
            searchInput.style.display = "block";
            searchInput.focus();
            searchInput.select();
        }
        if (e.key === "Escape") {
            if (sidebar.style.right === "0px" || sidebar.style.right === "0") closeSidebar();
            else resetSearch();
        }
    });

    searchInput.addEventListener("input", () => filterTasks(searchInput.value));
    searchInput.addEventListener("keydown", e => {
        if (e.key === "Enter") {
            const visible = getTasks().filter(t => t.style.display !== "none");
            if (visible.length === 1) {
                visible[0].click();
                resetSearch();
            }
        }
    });

    /* -------------------------------------------------------
       INITIAL RENDER
    --------------------------------------------------------- */
    renderFilters();

})();

  • feat: implement Kanban task bulk move from bucket to bucket
// ==UserScript==
// @name         Vikunja Kanban enhancer: Auto Label by Column + Purge Archive + Bulk move 
// @namespace    malys
// @version      5.0
// @description  Auto-apply column labels when a task is moved between Kanban columns in Vikunja — resolves short IDs and label IDs via API
// @match        https:/TODO/projects/*
// @match		 https://try.vikunja.io/*	
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ---------------------------
    // CONFIG
    // ---------------------------
    const DEBUG = false;                    // set to false to silence logs
    const TOKEN_KEY = 'token';             // change if your token is stored under a different key in localStorage
    const API_BASE_ORIGIN = window.location.origin; // uses same origin as the app
    const API_BASE = `${API_BASE_ORIGIN}/api/v1`;
    const DEBOUNCE_MS = 250;               // debounce processing after mutations


    const log = (...args) => { if (DEBUG) console.log('[AutoLabel]', ...args); };

    // ---------------------------
    // Auth helpers
    // ---------------------------
    function getToken() {
        return localStorage.getItem(TOKEN_KEY) || '';
    }

    function authHeaders(extra = {}) {
        const token = getToken();
        const base = { ...extra };
        if (token) base['Authorization'] = `Bearer ${token}`;
        if (!base['Accept']) base['Accept'] = 'application/json, text/plain, */*';
        return base;
    }

    // ---------------------------
    // URL helpers (project & view)
    // ---------------------------
    function getProjectAndViewFromUrl() {
        const m = window.location.pathname.match(/\/projects\/(\d+)\/(\d+)/);
        if (!m) return null;
        return { projectId: parseInt(m[1], 10), viewId: parseInt(m[2], 10) };
    }

    // ---------------------------
    // API helpers
    // ---------------------------
    async function fetchJson(url, opts = {}) {
        const res = await fetch(url, { credentials: 'include', ...opts });
        if (!res.ok) {
            const txt = await res.text().catch(() => '');
            throw new Error(`HTTP ${res.status}: ${txt}`);
        }
        return res.json();
    }

    async function loadViewTasks(projectId, viewId) {
        const url = `${API_BASE}/projects/${projectId}/views/${viewId}/tasks?filter=&filter_include_nulls=false&s=&per_page=100&page=1`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getTaskByLabel(projectId, viewId, labelId) {
        const url = `${API_BASE}/projects/${projectId}/tasks?filter=labels+in+${labelId}`;
        return await fetchJson(url, { headers: authHeaders() });
    }

    async function getAllLabels() {
        return await fetchJson(`${API_BASE}/labels`, { headers: authHeaders() });
    }


    async function createLabel(title) {
        return await fetchJson(`${API_BASE}/labels`, {
            method: 'PUT',
            headers: authHeaders({ 'Content-Type': 'application/json' }),
            body: JSON.stringify({ title })
        });
    }

    async function addLabelToTask(taskId, labelId) {
        // Vikunja UI used PUT to /tasks/:id/labels with a body — replicate that
        const url = `${API_BASE}/tasks/${taskId}/labels`;
        const body = JSON.stringify({ max_permission: null, id: 0, task_id: taskId, label_id: labelId });
        const res = await fetch(url, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, credentials: 'include' });
        if (!res.ok) throw new Error(`addLabel failed ${res.status}`);
        return res;
    }

    async function removeLabelFromTask(taskId, labelId) {
        const url = `${API_BASE}/tasks/${taskId}/labels/${labelId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function removeTask(taskId) {
        const url = `${API_BASE}/tasks/${taskId}`;
        const res = await fetch(url, { method: 'DELETE', headers: authHeaders(), credentials: 'include' });
        if (!res.ok) throw new Error(`removeLabel failed ${res.status}`);
        return res;
    }

    async function getTaskLabels(taskId) {
        return await fetchJson(`${API_BASE}/tasks/${taskId}/labels`, { headers: authHeaders() });
    }

    async function moveTask(taskId,bucketId,projectViewId,projectId) {
        const url = `${API_BASE}/projects/${projectId}/views/${projectViewId}/buckets/${bucketId}/tasks`;
        const body = JSON.stringify({ max_permission: null, id: 0, task_id: taskId, bucket_id: bucketId,project_view_id: projectViewId,project_id: projectId });
        const res = await fetch(url, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body, credentials: 'include' });
        if (!res.ok) throw new Error(`addLabel failed ${res.status}`);
        return res;   
    }

    // ---------------------------
    // Utilities: normalize column/label name
    // ---------------------------
    const normalize = s => (String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ''));

    // ---------------------------
    // Extract short ID from card DOM
    // ---------------------------
    function extractShortIdFromCard(card) {

        try {
            const idNode = card.querySelector('.task-id');
            if (!idNode) return null;
            const text = idNode.textContent || '';
            return text.replace("Done", "");
        } catch (e) {
            return null;
        }
    }

    // ---------------------------
    // Column name from card
    // ---------------------------
    function getColumnFromCard(card) {
        const bucket = card.closest('.bucket');
        if (!bucket) return null;
        const h2 = bucket.querySelector('h2.title');
        if (!h2) return null;
        return h2.textContent.trim();
    }

    function getAllColumnNames() {
        return Array.from(document.querySelectorAll('.bucket h2.title'))
            .map(h2 => (h2.textContent || '').trim())
            .filter(Boolean);
    }

    // ---------------------------
    // Main state
    // ---------------------------
    const cardColumn = new WeakMap();         // track last known column per card element
    let shortIdToNumeric = {};                // map shortId -> numeric id
    let labelTitleToObj = {};                 // map normalized label title -> label object
    let lastLoadAt = 0;

    // ---------------------------
    // Load view tasks & labels and build maps
    // ---------------------------
    async function refreshMaps() {
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;

        log('Loading view tasks for', projectId, viewId);
        try {
            const viewCols = await loadViewTasks(projectId, viewId);
            shortIdToNumeric = {};
            for (const col of viewCols) {
                if (!col.tasks) continue;
                for (const task of col.tasks) {
                    if (task.identifier) shortIdToNumeric[task.identifier.toUpperCase()] = task.id;
                }
            }
            log('Built shortId->id map', shortIdToNumeric);
        } catch (e) {
            console.error('[AutoLabel] Failed to load view tasks:', e);
        }

        log('Loading labels');
        try {
            const labels = await getAllLabels();
            labelTitleToObj = {};
            for (const l of labels) {
                labelTitleToObj[normalize(l.title)] = l;
            }
            log('Built label map', Object.keys(labelTitleToObj));
        } catch (e) {
            console.error('[AutoLabel] Failed to load labels:', e);
        }

        lastLoadAt = Date.now();
    }

    // ---------------------------
    // Resolve numeric id from short id (uses prebuilt map, or refreshes if missing)
    // ---------------------------
    async function resolveNumericId(shortId) {
        if (!shortId) return null;
        shortId = shortId.toUpperCase();
        if (shortIdToNumeric[shortId]) return shortIdToNumeric[shortId];

        // try refresh once
        await refreshMaps();
        return shortIdToNumeric[shortId] || null;
    }

    // ---------------------------
    // Find or create label object for column name
    // ---------------------------
    async function ensureLabelForColumn(columnName) {
        const key = normalize(columnName);
        let label = labelTitleToObj[key];
        if (label) return label;

        log('Label for column not found, creating:', columnName);
        try {
            label = await createLabel(columnName);
            labelTitleToObj[normalize(label.title)] = label;
            return label;
        } catch (e) {
            console.error('[AutoLabel] Failed to create label:', e);
            return null;
        }
    }

    // ---------------------------
    // Core: handle a card move
    // ---------------------------
    async function handleCardMove(card) {
        try {
            const shortId = extractShortIdFromCard(card);
            if (DEBUG) log('shortId', shortId);
            if (!shortId) return;

            const numericId = await resolveNumericId(shortId);
            if (DEBUG) log('numericId', numericId);
            if (!numericId) {
                log('Could not resolve numeric id for', shortId);
                return;
            }

            const colName = getColumnFromCard(card);
            if (DEBUG) log('colName', colName);
            if (!colName) return;
            const normalizedCol = normalize(colName);

            log(`Task ${shortId} (${numericId}) moved to column '${colName}'`);

            // ensure label exists for this column
            const labelObj = await ensureLabelForColumn(colName);
            if (DEBUG) log('labelObj', labelObj);
            if (!labelObj) return;

            // fetch current labels on task
            let currentLabels = [];
            try {
                currentLabels = await getTaskLabels(numericId);
                if (DEBUG) log('currentLabels', currentLabels);
            } catch (e) {
                console.error('[AutoLabel] Failed to get task labels', e);
            }
            if (currentLabels == null) {
                currentLabels = [];
            }

            // remove labels that correspond to other bucket names (allow non-column labels)
            const bucketNameSet = new Set(getAllColumnNames().map(normalize));
            for (const old of currentLabels) {
                const normalizedOld = normalize(old.title);
                if (bucketNameSet.has(normalizedOld) && normalizedOld !== normalizedCol) {
                    try {
                        log('Removing label', old.title, 'from task', numericId);
                        await removeLabelFromTask(numericId, old.id);
                    } catch (e) {
                        console.error('[AutoLabel] failed remove label', e);
                    }
                }
            }

            // add target label if not present
            const already = currentLabels.some(l => normalize(l.title) === normalizedCol);
            if (!already) {
                try {
                    log('Adding label', labelObj.title, 'to task', numericId);
                    await addLabelToTask(numericId, labelObj.id);
                } catch (e) {
                    console.error('[AutoLabel] failed add label', e);
                }
            } else {
                log('Task already has target label');
            }
        } catch (e) {
            console.error('[AutoLabel] handleCardMove exception', e);
        }
    }

    // ---------------------------
    // Process DOM: scan cards and detect column changes
    // ---------------------------
    async function processMoves() {
        // refresh maps periodically (every 60s) to stay in sync
        if (Date.now() - lastLoadAt > 60_000) await refreshMaps();

        document.querySelectorAll('.kanban-card').forEach(card => {
            const col = getColumnFromCard(card);
            if (!col) return;
            const prev = cardColumn.get(card);
            if (prev === col) return;
            cardColumn.set(card, col);
            handleCardMove(card);
        });
    }

    // ---------------------------
    // MutationObserver + debounce
    // ---------------------------
    let debounceTimer = null;
    const mo = new MutationObserver((mutations) => {
        if (DEBUG) log('Mutations', mutations.map(m => ({ type: m.type, added: m.addedNodes.length, removed: m.removedNodes.length })));
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => { Promise.resolve().then(processMoves); }, DEBOUNCE_MS);
    });

    // Extra: listen to dragend to force-scan
    document.addEventListener('dragend', e => {
        if (e.target?.classList?.contains('kanban-card')) {
            log('dragend fired, scanning');
            setTimeout(processMoves, 100); // small delay to let Vue patch
        }
    });

    // ---------------------------
    // Init
    // ---------------------------
    (async function init() {
        log('Starting Vikunja AutoLabel script');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view page; aborting.');
            return;
        }

        await refreshMaps();

        mo.observe(document.body, { childList: true, subtree: true });
        log('Observer active — will auto-label moves.');

        // initial scan
        setTimeout(() => { processMoves(); }, 500);

        // Run when kanban loads
        waitForKanban().then(injectCleanupUI);
    })();

    function waitForKanban() {
        return new Promise(resolve => {
            const check = () => {
                if (document.querySelector(".kanban-view")) resolve();
                else setTimeout(check, 300);
            };
            check();
        });
    }

    /* =====================================================================
       CLEANUP PERSISTENT SETTINGS UI
       ===================================================================== */

    const CLEAN_STORAGE_KEY = "kanban_cleanup_persistent_v1_origin";

    function loadCleanupConfig() {
        try {
            const obj = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
            return obj[window.location.origin] || { days: 30 };
        } catch (e) {
            return { days: 30 };
        }
    }

    function saveCleanupConfig(cfg) {
        let all = {};
        try {
            all = JSON.parse(localStorage.getItem(CLEAN_STORAGE_KEY) || "{}");
        } catch (e) { }
        all[window.location.origin] = cfg;
        localStorage.setItem(CLEAN_STORAGE_KEY, JSON.stringify(all));
    }

    /********************************************************************
      *  UI: SINGLE BUTTON + SLIDE PANEL
      ********************************************************************/
    function injectCleanupUI() {
        if (document.getElementById("cleanupSidebar")) return;

        const DARK = window.matchMedia("(prefers-color-scheme: dark)").matches;
        const C = DARK
            ? {
                panel: "#262626",
                border: "#333",
                text: "#eee",
                button: "#3a6ee8",
                bg: "#1f1f1f",
                shadow: "rgba(0,0,0,0.6)",
            }
            : {
                panel: "#f7f7f7",
                border: "#ccc",
                text: "#333",
                button: "#4a90e2",
                bg: "#fff",
                shadow: "rgba(0,0,0,0.25)",
            };

        /***********************
         * Button
         ************************/
        const button = document.createElement("div");
        button.id = "cleanupMainBtn";
        button.textContent = "🧹 Clean Archived";
        button.style.cssText = `
            position: fixed;
            bottom: 70px;
            right: 20px;
            background: ${C.button};
            color: white;
            padding: 11px 16px;
            border-radius: 30px;
            cursor: pointer;
            font-size: 14px;
            z-index: 999999;
            box-shadow: 0 4px 12px ${C.shadow};
            user-select: none;
        `;
        document.body.appendChild(button);

        const bulkBtn = document.createElement("div");
        bulkBtn.id = "bulkMoveMainBtn";
        bulkBtn.textContent = "📦 Bulk move";
        bulkBtn.style.cssText = `
            position: fixed;
            bottom: 120px;
            right: 20px;
            background: ${C.button};
            color: white;
            padding: 11px 16px;
            border-radius: 30px;
            cursor: pointer;
            font-size: 14px;
            z-index: 999999;
            box-shadow: 0 4px 12px ${C.shadow};
            user-select: none;
        `;
        document.body.appendChild(bulkBtn);

        /***********************
         * Slide panel
         ************************/
        const panel = document.createElement("div");
        panel.id = "cleanupSidebar";
        panel.style.cssText = `
            position: fixed;
            top: 0;
            right: -350px;
            width: 330px;
            height: 100%;
            background: ${C.panel};
            color: ${C.text};
            border-left: 1px solid ${C.border};
            box-shadow: -4px 0 20px ${C.shadow};
            padding: 16px;
            box-sizing: border-box;
            z-index: 999998;
            transition: right .25s ease;
            font-family: sans-serif;
        `;

        const cfg = loadCleanupConfig();

        panel.innerHTML = `
            <h3 style="margin: 0 0 12px;">Clean Archived</h3>

            <label style="font-size: 14px;">Delete archived tasks older than (days):</label>
            <input id="cleanupDaysInput" type="number" min="1"
                style="width: 100%; margin: 8px 0 20px; padding: 8px;
                border: 1px solid ${C.border};
                border-radius: 6px; background: ${C.bg};
                color: ${C.text};"
            >

            <button id="cleanupSaveBtn" style="
                width:100%; padding:10px; background:${C.button};
                color:white; border:none; border-radius:6px;
                cursor:pointer; margin-bottom:16px;">
                Save Settings
            </button>

            <button id="cleanupRunBtn" style="
                width:100%; padding:12px; background:crimson;
                color:white; border:none; border-radius:6px;
                cursor:pointer; font-size:15px; font-weight:bold;">
                🧨 Run Clean Now
            </button>

            <div style="margin-top:14px; font-size:12px; opacity:.7;">
                Settings stored for this domain.<br>
                “Run Clean Now” applies the current values.
            </div>
        `;
        document.body.appendChild(panel);

        const bulkPanel = document.createElement("div");
        bulkPanel.id = "bulkMoveSidebar";
        bulkPanel.style.cssText = `
            position: fixed;
            top: 0;
            right: -350px;
            width: 330px;
            height: 100%;
            background: ${C.panel};
            color: ${C.text};
            border-left: 1px solid ${C.border};
            box-shadow: -4px 0 20px ${C.shadow};
            padding: 16px;
            box-sizing: border-box;
            z-index: 999998;
            transition: right .25s ease;
            font-family: sans-serif;
        `;
        bulkPanel.innerHTML = `
            <h3 style="margin: 0 0 12px;">Bulck move</h3>
            <label style="font-size: 14px;">From bucket:</label>
            <select id="bulkFromSelect" style="width:100%; margin:8px 0 12px; padding:8px; border:1px solid ${C.border}; border-radius:6px; background:${C.bg}; color:${C.text};"></select>
            <label style="font-size: 14px;">To bucket:</label>
            <select id="bulkToSelect" style="width:100%; margin:8px 0 20px; padding:8px; border:1px solid ${C.border}; border-radius:6px; background:${C.bg}; color:${C.text};"></select>
            <button id="bulkRunBtn" style="width:100%; padding:12px; background:${C.button}; color:white; border:none; border-radius:6px; cursor:pointer; font-size:15px; font-weight:bold;">▶ Run Move</button>
            <div style="margin-top:14px; font-size:12px; opacity:.7;">Select source and destination buckets. You'll be asked to confirm the list of tasks to move.</div>
        `;
        document.body.appendChild(bulkPanel);

        /***********************
         * Events
         ************************/
        const input = panel.querySelector("#cleanupDaysInput");
        const saveBtn = panel.querySelector("#cleanupSaveBtn");
        const runBtn = panel.querySelector("#cleanupRunBtn");
        const bulkFromSel = bulkPanel.querySelector("#bulkFromSelect");
        const bulkToSel = bulkPanel.querySelector("#bulkToSelect");
        const bulkRunBtn = bulkPanel.querySelector("#bulkRunBtn");
        input.value = cfg.days;

        button.onclick = () => {
            panel.style.right = "0";
        };

        saveBtn.onclick = () => {
            const days = Number(input.value);
            if (!days || days < 1) {
                alert("Invalid days.");
                return;
            }
            saveCleanupConfig({ days });
            alert("Saved ✔");
        };

        runBtn.onclick = () => {
            panel.style.right = "-350px";
            bulkCleanArchived();
        };

        bulkBtn.onclick = async () => {
            const pv = getProjectAndViewFromUrl();
            if (!pv) return;
            const { projectId, viewId } = pv;
            try {
                const cols = await loadViewTasks(projectId, viewId);
                const names = cols.map(c => c.title).filter(Boolean);
                bulkFromSel.innerHTML = names.map(n => `<option${n.toLowerCase()==='done'?' selected':''}>${n}</option>`).join("");
                bulkToSel.innerHTML = names.map(n => `<option${n.toLowerCase()==='archive'?' selected':''}>${n}</option>`).join("");
                bulkPanel.style.right = "0";
            } catch (e) {
                alert("Failed to load buckets.");
            }
        };

        bulkRunBtn.onclick = async () => {
            bulkPanel.style.right = "-350px";
            const fromName = bulkFromSel.value;
            const toName = bulkToSel.value;
            await bulkMoveTasks(fromName, toName);
        };

        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape") panel.style.right = "-350px";
        });
    }

    async function bulkCleanArchived() {
        // Load existing config
        const cfg = loadCleanupConfig();

        if (DEBUG) log('bulkCleanArchive start');
        const pv = getProjectAndViewFromUrl();
        if (!pv) {
            log('Not on a project view URL, skipping map refresh');
            return;
        }
        const { projectId, viewId } = pv;
        if (DEBUG) log('bulkCleanArchive projectId', projectId, 'viewId', viewId);

        const labels = await getAllLabels();
        const labelId = labels.find(l => l.title.toLowerCase() === "archive").id;
        if (!labelId) {
            log('No archive label found');
            return;
        }
        if (DEBUG) log('bulkCleanArchive labelId', labelId);

        const tasks = await getTaskByLabel(projectId, viewId, labelId)
        if (tasks.length === 0) {
            alert("No tasks in Archive.");
            return;
        }
        if (DEBUG) log('bulkCleanArchive tasks', tasks.length);

        const oldTasks = [];
        const oneMonthAgo = Date.now() - cfg.days * 24 * 60 * 60 * 1000;
        const delayLabel = cfg.days + " day(s)";

        for (const t of tasks) {
            const updated = new Date(t.updated).getTime();
            if (DEBUG) log('bulkCleanArchive task', t.id, updated);

            if (updated < oneMonthAgo) {
                oldTasks.push({
                    id: t.id,
                    shortid: t.identifier,
                    title: t.title,
                    updated: t.updated
                });
            }
        }

        if (oldTasks.length === 0) {
            alert(`No archived tasks older than ${delayLabel}.`);
            return;
        }

        const list = oldTasks
            .map(t => `• [${t.shortid}] ${t.title} (updated: ${t.updated})`)
            .join("\n");

        const ok = confirm(
            `Delete the following ${oldTasks.length} archived tasks?\n\n${list}\n\nThis cannot be undone.`
        );
        if (!ok) return;

        for (const t of oldTasks) {
            if (DEBUG) log('bulkCleanArchive delete', t.title);
            await removeTask(t.id)
        }

        alert(`Deleted ${oldTasks.length} old archived tasks.\nReloading...`);
        location.reload();
    }

    async function bulkMoveTasks(fromName, toName) {
        const pv = getProjectAndViewFromUrl();
        if (!pv) return;
        const { projectId, viewId } = pv;
        if (!fromName || !toName) {
            alert("Please select both buckets.");
            return;
        }
        if (fromName === toName) {
            alert("'From' and 'To' buckets must be different.");
            return;
        }
        try {
            const cols = await loadViewTasks(projectId, viewId);
            const fromCol = cols.find(c => (c.title || '').toLowerCase() === fromName.toLowerCase());
            const toCol = cols.find(c => (c.title || '').toLowerCase() === toName.toLowerCase());
            if (!fromCol) { alert(`Bucket not found: ${fromName}`); return; }
            if (!toCol) { alert(`Bucket not found: ${toName}`); return; }
            const tasks = Array.isArray(fromCol.tasks) ? fromCol.tasks : [];
            if (tasks.length === 0) { alert(`No tasks in '${fromName}'.`); return; }
            const list = tasks.map(t => `• [${t.identifier || t.id}] ${t.title}`).join("\n");
            const ok = confirm(`Move ${tasks.length} tasks from '${fromName}' to '${toName}'?\n\n${list}`);
            if (!ok) return;
            let moved = 0;
            for (const t of tasks) {
                try {
                    await moveTask(t.id, toCol.id, viewId, projectId);
                    moved++;
                } catch (e) { }
            }
            alert(`Moved ${moved}/${tasks.length} tasks. Reloading...`);
            location.reload();
        } catch (e) {
            alert("Bulk move failed.");
        }
    }

})();

Demo:

  • On typing filter
  • Auto Labels
  • Bulk move

vikunja3

It could be interesting to have these features natively? no?