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);
})();
})();


