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?

This looks great!

The challenge here would be to integrate these in a way that makes it worthwhile for power users (I would consider all of them power user feature) while not getting in the way of regular users.

As I see it:

  • The quick filtering could be integrated into Vikunja with a shortcut to open the filter panel (you can already just type there, and it will search for tasks by title and description, without any fancy syntax)
  • For the auto labels: Since this doubles the information that is already in the bucket title, what is the use-case for this?
  • The bigger vision for bulk move would of course be bulk edit, which has still quite a way to go
  1. In profile of user, It could be interesting to be allowed to enable features (filter labels,…)
  2. Quick filter is not very difficult to implement and very interesting
  3. i use auto label to synchronize different views on the same bucket state. Because associated bucket is an attribute of view and not a task attribute.
  4. Bulk move, delete to my mind is an high priority compared to bulk edit. For men massive edition is not frequent.

My next features will to add the number of task for each bucket in bucket title and probably bulk delete with multi selection.

Yes, some of these features probably should be gated behind a user setting.

The task count already exists in the api response but is not surfaced if no limit is specified. I think there was a feature request somewhere about making this always available.

Here’s a PR for the bucket count: https://github.com/go-vikunja/vikunja/pull/1966

1 Like

Thanks For your feedback and your work, i will create a limit to force it

The PR is done and merged - now there is a setting in the frontend that allows you to always show the task count on a bucket. Please check with the next unstable build (should be ready for deployment in ~30min, also on try).

Thanks, I tried it in demo platform; it seems great.
I was blocked to migrate from 1.0.0-rc2 to rc3 because Migration issue from 1.0.0-rc2 to 1.0.0-rc3 - #3 by malys. Can I try again?

feat: Merge both script and unify UI.
feat: enable/disable features as you want
fix: bug Escape to close right panel
chore: refactor to modular architecture

I ask if this script is useful for community to continue or not to share updates?

NEW feature: Task templates

  • Create/Modify/Delete Task templates
  • Create from existing task

template

Available as userscript

FYI this user script respects privacy.
Code is a mix of vibe coding and my own code. It’s covering my needs and probably have many bugs.
But, it’s a quick and dirty solution to cover my needs.
The best solution will be to have a native integration in vikunja but it’s not same cycle of development and quality gates.
Feel free to use it and improve it.