Skip to content

Instantly share code, notes, and snippets.

@malys
Created December 14, 2025 16:36
Show Gist options
  • Select an option

  • Save malys/34cfe07c83fb804d27087127e2d63149 to your computer and use it in GitHub Desktop.

Select an option

Save malys/34cfe07c83fb804d27087127e2d63149 to your computer and use it in GitHub Desktop.
[Vikunja] kanban enhancer #userscript #violentmonkey
// ==UserScript==
// @name Vikunja Kanban enhancer
// @description Auto Label by Column + Purge Archive + Bulk move + Quick filters (import, export, merge)
// @namespace malys
// @version 6.0
// @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 FEATURE_FLAGS_KEY = "vikunja_helper_feature_flags_v1";
const FEATURE_DEFAULTS = {
autoLabel: true,
cleanup: true,
bulkMove: true,
filters: true,
};
const THEME = getThemeColors();
function whenReady(fn) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", fn, { once: true });
} else {
fn();
}
}
function getThemeColors() {
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark
? {
bg: "#1f1f1f",
panel: "#262626",
border: "#333",
text: "#eee",
button: "#3a6ee8",
buttonText: "#fff",
shadow: "rgba(0,0,0,0.6)",
}
: {
bg: "#ffffff",
panel: "#f7f7f7",
border: "#d0d0d0",
text: "#222",
button: "#4a90e2",
buttonText: "#fff",
shadow: "rgba(0,0,0,0.2)",
};
}
function loadFeatureFlags() {
try {
const parsed = JSON.parse(localStorage.getItem(FEATURE_FLAGS_KEY) || "{}");
return { ...FEATURE_DEFAULTS, ...parsed };
} catch (e) {
return { ...FEATURE_DEFAULTS };
}
}
function saveFeatureFlags(flags) {
localStorage.setItem(FEATURE_FLAGS_KEY, JSON.stringify(flags));
}
const log = (...args) => { if (DEBUG) console.log('[AutoLabel]', ...args); };
const FeatureManager = (() => {
const features = new Map();
let flags = loadFeatureFlags();
let menuButton = null;
let menuSidebar = null;
let featureList = null;
let escHandler = null;
function registerFeature(key, config) {
features.set(key, { ...config, started: false });
}
function init() {
whenReady(() => {
buildMenu();
features.forEach((_cfg, key) => {
if (flags[key]) startFeature(key, { silent: true });
});
renderMenu();
});
}
function buildMenu() {
if (menuButton) return;
menuButton = document.createElement("div");
menuButton.id = "vkToolsMainBtn";
menuButton.textContent = "βš™οΈ Vikunja tools";
menuButton.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: ${THEME.button};
color: ${THEME.buttonText};
padding: 12px 18px;
border-radius: 30px;
cursor: pointer;
font-size: 14px;
z-index: 999999;
box-shadow: 0 4px 12px ${THEME.shadow};
user-select: none;
`;
document.body.appendChild(menuButton);
menuSidebar = document.createElement("div");
menuSidebar.id = "vkToolsSidebar";
menuSidebar.style.cssText = `
position: fixed;
top: 0;
right: -380px;
width: 360px;
height: 100%;
background: ${THEME.panel};
color: ${THEME.text};
border-left: 1px solid ${THEME.border};
box-shadow: -4px 0 20px ${THEME.shadow};
z-index: 999998;
transition: right 0.25s ease;
padding: 18px;
font-family: sans-serif;
box-sizing: border-box;
`;
menuSidebar.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="margin:0; font-size:18px;">Vikunja helper</h3>
<button id="vkToolsCloseBtn" style="
border:none; background:${THEME.border};
color:${THEME.text}; border-radius:50%;
width:28px; height:28px; cursor:pointer;">βœ•</button>
</div>
<div style="font-size:13px; opacity:0.75; margin-bottom:12px;">
Toggle features on demand and launch their panels.
</div>
<div id="vkToolsFeatureList" style="display:flex; flex-direction:column; gap:12px;"></div>
`;
document.body.appendChild(menuSidebar);
featureList = menuSidebar.querySelector("#vkToolsFeatureList");
menuButton.addEventListener("click", openMenu);
menuSidebar.querySelector("#vkToolsCloseBtn").addEventListener("click", closeMenu);
escHandler = (e) => {
if (e.key === "Escape") closeMenu();
};
document.addEventListener("keydown", escHandler);
}
function openMenu() {
menuSidebar.style.right = "0";
}
function closeMenu() {
menuSidebar.style.right = "-380px";
}
function renderMenu() {
if (!featureList) return;
featureList.innerHTML = "";
features.forEach((config, key) => {
const row = document.createElement("div");
row.style.cssText = `
border:1px solid ${THEME.border};
border-radius:8px;
padding:12px;
display:flex;
flex-direction:column;
gap:10px;
background:${THEME.bg};
`;
const topRow = document.createElement("div");
topRow.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
const textWrap = document.createElement("div");
const title = document.createElement("div");
title.textContent = config.label;
title.style.cssText = "font-weight:600; font-size:15px;";
const desc = document.createElement("div");
desc.textContent = config.description || "";
desc.style.cssText = "font-size:12px; opacity:0.75;";
textWrap.appendChild(title);
if (config.description) textWrap.appendChild(desc);
const toggle = document.createElement("label");
toggle.style.cssText = "display:flex; align-items:center; cursor:pointer;";
toggle.innerHTML = `
<input type="checkbox" style="width:0;height:0;opacity:0;position:absolute;">
<span style="
width:40px; height:20px; background:${THEME.border};
border-radius:20px; position:relative; display:inline-block; transition:background .2s;">
<span style="
position:absolute; top:2px; left:2px; width:16px; height:16px;
background:${THEME.bg}; border-radius:50%; transition:transform .2s;"></span>
</span>
`;
const input = toggle.querySelector("input");
const slider = toggle.querySelector("span");
const knob = slider.querySelector("span");
const updateToggleVisual = (checked) => {
slider.style.background = checked ? THEME.button : THEME.border;
knob.style.transform = checked ? "translateX(20px)" : "translateX(0)";
};
input.checked = !!flags[key];
updateToggleVisual(input.checked);
toggle.addEventListener("click", (evt) => evt.stopPropagation());
slider.addEventListener("click", (evt) => {
evt.preventDefault();
const next = !input.checked;
next ? startFeature(key) : stopFeature(key);
});
topRow.appendChild(textWrap);
topRow.appendChild(toggle);
row.appendChild(topRow);
if (config.openPanel) {
const actions = document.createElement("div");
const btn = document.createElement("button");
btn.textContent = config.actionLabel || "Open panel";
btn.style.cssText = `
width:100%; padding:9px; border:none;
background:${THEME.button}; color:${THEME.buttonText};
border-radius:6px; cursor:pointer; font-size:13px;
`;
btn.disabled = !flags[key];
btn.style.opacity = btn.disabled ? "0.6" : "1";
btn.addEventListener("click", () => config.openPanel());
actions.appendChild(btn);
row.appendChild(actions);
}
featureList.appendChild(row);
});
}
function startFeature(key, { silent } = {}) {
const config = features.get(key);
if (!config || config.started) return;
flags[key] = true;
saveFeatureFlags(flags);
config.started = true;
try {
config.start?.();
} catch (err) {
console.error(`Failed to start feature ${key}`, err);
}
if (!silent) renderMenu();
}
function stopFeature(key) {
const config = features.get(key);
if (!config || !config.started) return;
flags[key] = false;
saveFeatureFlags(flags);
config.started = false;
try {
config.stop?.();
} catch (err) {
console.error(`Failed to stop feature ${key}`, err);
}
renderMenu();
}
return {
registerFeature,
init,
startFeature,
stopFeature,
getFlags: () => ({ ...flags }),
};
})();
// ---------------------------
// 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);
}
function waitForKanban() {
return new Promise(resolve => {
const check = () => {
if (document.querySelector(".kanban-view")) resolve();
else setTimeout(check, 300);
};
check();
});
}
// ---------------------------
// Feature: Auto label
// ---------------------------
const AUTO_LABEL_STATE = {
cardColumn: new WeakMap(),
shortIdToNumeric: {},
labelTitleToObj: {},
lastLoadAt: 0,
debounceTimer: null,
observer: null,
dragHandler: null,
active: false,
};
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);
AUTO_LABEL_STATE.shortIdToNumeric = {};
for (const col of viewCols) {
if (!col.tasks) continue;
for (const task of col.tasks) {
if (task.identifier) {
AUTO_LABEL_STATE.shortIdToNumeric[task.identifier.toUpperCase()] = task.id;
}
}
}
log('Built shortId->id map', AUTO_LABEL_STATE.shortIdToNumeric);
} catch (e) {
console.error('[AutoLabel] Failed to load view tasks:', e);
}
log('Loading labels');
try {
const labels = await getAllLabels();
AUTO_LABEL_STATE.labelTitleToObj = {};
for (const l of labels) {
AUTO_LABEL_STATE.labelTitleToObj[normalize(l.title)] = l;
}
log('Built label map', Object.keys(AUTO_LABEL_STATE.labelTitleToObj));
} catch (e) {
console.error('[AutoLabel] Failed to load labels:', e);
}
AUTO_LABEL_STATE.lastLoadAt = Date.now();
}
async function resolveNumericId(shortId) {
if (!shortId) return null;
const normalized = shortId.toUpperCase();
if (AUTO_LABEL_STATE.shortIdToNumeric[normalized]) return AUTO_LABEL_STATE.shortIdToNumeric[normalized];
await refreshMaps();
return AUTO_LABEL_STATE.shortIdToNumeric[normalized] || null;
}
async function ensureLabelForColumn(columnName) {
const key = normalize(columnName);
let label = AUTO_LABEL_STATE.labelTitleToObj[key];
if (label) return label;
log('Label for column not found, creating:', columnName);
try {
label = await createLabel(columnName);
AUTO_LABEL_STATE.labelTitleToObj[normalize(label.title)] = label;
return label;
} catch (e) {
console.error('[AutoLabel] Failed to create label:', e);
return null;
}
}
async function handleCardMove(card) {
if (!AUTO_LABEL_STATE.active) return;
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}'`);
const labelObj = await ensureLabelForColumn(colName);
if (DEBUG) log('labelObj', labelObj);
if (!labelObj) return;
let currentLabels = [];
try {
currentLabels = await getTaskLabels(numericId);
if (DEBUG) log('currentLabels', currentLabels);
} catch (e) {
console.error('[AutoLabel] Failed to get task labels', e);
}
if (!Array.isArray(currentLabels)) currentLabels = [];
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);
}
}
}
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);
}
}
async function processMoves() {
if (!AUTO_LABEL_STATE.active) return;
if (Date.now() - AUTO_LABEL_STATE.lastLoadAt > 60_000) await refreshMaps();
document.querySelectorAll('.kanban-card').forEach(card => {
const col = getColumnFromCard(card);
if (!col) return;
const prev = AUTO_LABEL_STATE.cardColumn.get(card);
if (prev === col) return;
AUTO_LABEL_STATE.cardColumn.set(card, col);
handleCardMove(card);
});
}
function setupAutoLabelObservers() {
if (AUTO_LABEL_STATE.observer) return;
const observer = new MutationObserver((mutations) => {
if (!AUTO_LABEL_STATE.active) return;
if (DEBUG) log('Mutations', mutations.map(m => ({ type: m.type, added: m.addedNodes.length, removed: m.removedNodes.length })));
clearTimeout(AUTO_LABEL_STATE.debounceTimer);
AUTO_LABEL_STATE.debounceTimer = setTimeout(() => { Promise.resolve().then(processMoves); }, DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
AUTO_LABEL_STATE.observer = observer;
const dragHandler = (e) => {
if (!AUTO_LABEL_STATE.active) return;
if (e.target?.classList?.contains('kanban-card')) {
log('dragend fired, scanning');
setTimeout(processMoves, 100);
}
};
AUTO_LABEL_STATE.dragHandler = dragHandler;
document.addEventListener('dragend', dragHandler);
}
function teardownAutoLabelObservers() {
if (AUTO_LABEL_STATE.observer) {
AUTO_LABEL_STATE.observer.disconnect();
AUTO_LABEL_STATE.observer = null;
}
if (AUTO_LABEL_STATE.dragHandler) {
document.removeEventListener('dragend', AUTO_LABEL_STATE.dragHandler);
AUTO_LABEL_STATE.dragHandler = null;
}
if (AUTO_LABEL_STATE.debounceTimer) {
clearTimeout(AUTO_LABEL_STATE.debounceTimer);
AUTO_LABEL_STATE.debounceTimer = null;
}
}
function startAutoLabelFeature() {
if (AUTO_LABEL_STATE.active) return;
AUTO_LABEL_STATE.active = true;
whenReady(async () => {
if (!getProjectAndViewFromUrl()) {
log('Auto label feature active but not on a project view page; aborting.');
return;
}
await refreshMaps();
if (!AUTO_LABEL_STATE.active) return;
waitForKanban().then(() => {
if (!AUTO_LABEL_STATE.active) return;
setupAutoLabelObservers();
processMoves();
});
});
}
function stopAutoLabelFeature() {
if (!AUTO_LABEL_STATE.active) return;
AUTO_LABEL_STATE.active = false;
teardownAutoLabelObservers();
AUTO_LABEL_STATE.cardColumn = new WeakMap();
AUTO_LABEL_STATE.shortIdToNumeric = {};
AUTO_LABEL_STATE.labelTitleToObj = {};
}
/* =====================================================================
CLEANUP & BULK MOVE PANELS
===================================================================== */
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));
}
const MAINTENANCE_UI = {
built: false,
cleanupPanel: null,
bulkPanel: null,
cleanupInput: null,
cleanupSaveBtn: null,
cleanupRunBtn: null,
bulkFromSel: null,
bulkToSel: null,
bulkRunBtn: null,
escHandler: null,
cleanupActive: false,
bulkActive: false,
};
function ensureMaintenanceUI() {
if (MAINTENANCE_UI.built) return;
const C = {
panel: THEME.panel,
border: THEME.border,
text: THEME.text,
button: THEME.button,
bg: THEME.bg,
buttonText: THEME.buttonText,
shadow: THEME.shadow,
};
const cleanupPanel = document.createElement("div");
cleanupPanel.id = "cleanupSidebar";
cleanupPanel.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;
`;
cleanupPanel.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:${C.buttonText}; 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” uses the saved values.
</div>
`;
document.body.appendChild(cleanupPanel);
const bulkPanel = document.createElement("div");
bulkPanel.id = "bulkMoveSidebar";
bulkPanel.style.cssText = cleanupPanel.style.cssText;
bulkPanel.innerHTML = `
<h3 style="margin: 0 0 12px;">Bulk 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:${C.buttonText}; 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 before moving.</div>
`;
document.body.appendChild(bulkPanel);
MAINTENANCE_UI.cleanupPanel = cleanupPanel;
MAINTENANCE_UI.bulkPanel = bulkPanel;
MAINTENANCE_UI.cleanupInput = cleanupPanel.querySelector("#cleanupDaysInput");
MAINTENANCE_UI.cleanupSaveBtn = cleanupPanel.querySelector("#cleanupSaveBtn");
MAINTENANCE_UI.cleanupRunBtn = cleanupPanel.querySelector("#cleanupRunBtn");
MAINTENANCE_UI.bulkFromSel = bulkPanel.querySelector("#bulkFromSelect");
MAINTENANCE_UI.bulkToSel = bulkPanel.querySelector("#bulkToSelect");
MAINTENANCE_UI.bulkRunBtn = bulkPanel.querySelector("#bulkRunBtn");
MAINTENANCE_UI.cleanupSaveBtn.addEventListener("click", handleCleanupSave);
MAINTENANCE_UI.cleanupRunBtn.addEventListener("click", () => {
if (!MAINTENANCE_UI.cleanupActive) return;
closeCleanupPanel();
bulkCleanArchived();
});
MAINTENANCE_UI.bulkRunBtn.addEventListener("click", async () => {
if (!MAINTENANCE_UI.bulkActive) return;
closeBulkPanel();
const fromName = MAINTENANCE_UI.bulkFromSel.value;
const toName = MAINTENANCE_UI.bulkToSel.value;
await bulkMoveTasks(fromName, toName);
});
MAINTENANCE_UI.built = true;
}
function handleCleanupSave() {
if (!MAINTENANCE_UI.cleanupActive) return;
const days = Number(MAINTENANCE_UI.cleanupInput.value);
if (!days || days < 1) {
alert("Invalid days.");
return;
}
saveCleanupConfig({ days });
alert("Saved βœ”");
}
function openCleanupPanel() {
if (!MAINTENANCE_UI.cleanupActive) return;
ensureMaintenanceUI();
const cfg = loadCleanupConfig();
MAINTENANCE_UI.cleanupInput.value = cfg.days;
MAINTENANCE_UI.cleanupPanel.style.right = "0";
}
function closeCleanupPanel() {
if (MAINTENANCE_UI.cleanupPanel) {
MAINTENANCE_UI.cleanupPanel.style.right = "-350px";
}
}
function openBulkPanel() {
if (!MAINTENANCE_UI.bulkActive) return;
ensureMaintenanceUI();
populateBulkSelectors();
MAINTENANCE_UI.bulkPanel.style.right = "0";
}
function closeBulkPanel() {
if (MAINTENANCE_UI.bulkPanel) {
MAINTENANCE_UI.bulkPanel.style.right = "-350px";
}
}
function ensureMaintenanceEsc() {
if (MAINTENANCE_UI.escHandler) return;
MAINTENANCE_UI.escHandler = (e) => {
if (e.key === "Escape") {
closeCleanupPanel();
closeBulkPanel();
}
};
document.addEventListener("keydown", MAINTENANCE_UI.escHandler);
}
function detachMaintenanceEsc() {
if (!MAINTENANCE_UI.escHandler) return;
document.removeEventListener("keydown", MAINTENANCE_UI.escHandler);
MAINTENANCE_UI.escHandler = null;
}
function startCleanupFeature() {
if (MAINTENANCE_UI.cleanupActive) return;
MAINTENANCE_UI.cleanupActive = true;
whenReady(() => {
ensureMaintenanceUI();
ensureMaintenanceEsc();
const cfg = loadCleanupConfig();
MAINTENANCE_UI.cleanupInput.value = cfg.days;
});
}
function stopCleanupFeature() {
if (!MAINTENANCE_UI.cleanupActive) return;
MAINTENANCE_UI.cleanupActive = false;
closeCleanupPanel();
if (!MAINTENANCE_UI.bulkActive) detachMaintenanceEsc();
}
function startBulkMoveFeature() {
if (MAINTENANCE_UI.bulkActive) return;
MAINTENANCE_UI.bulkActive = true;
whenReady(() => {
ensureMaintenanceUI();
ensureMaintenanceEsc();
});
}
function stopBulkMoveFeature() {
if (!MAINTENANCE_UI.bulkActive) return;
MAINTENANCE_UI.bulkActive = false;
closeBulkPanel();
if (!MAINTENANCE_UI.cleanupActive) detachMaintenanceEsc();
}
async function populateBulkSelectors() {
if (!MAINTENANCE_UI.bulkActive) return;
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);
const fromOptions = names.map(n => `<option${n.toLowerCase() === 'done' ? ' selected' : ''}>${n}</option>`).join("");
const toOptions = names.map(n => `<option${n.toLowerCase() === 'archive' ? ' selected' : ''}>${n}</option>`).join("");
MAINTENANCE_UI.bulkFromSel.innerHTML = fromOptions;
MAINTENANCE_UI.bulkToSel.innerHTML = toOptions;
} catch (e) {
alert("Failed to load buckets.");
}
}
async function bulkCleanArchived() {
const cfg = loadCleanupConfig();
if (DEBUG) log('bulkCleanArchive start');
const pv = getProjectAndViewFromUrl();
if (!pv) {
log('Not on a project view URL, skipping clean');
return;
}
const { projectId, viewId } = pv;
if (DEBUG) log('bulkCleanArchive projectId', projectId, 'viewId', viewId);
const labels = await getAllLabels();
const archiveLabel = labels.find(l => l.title?.toLowerCase() === "archive");
if (!archiveLabel) {
log('No archive label found');
return;
}
const tasks = await getTaskByLabel(projectId, viewId, archiveLabel.id);
if (!Array.isArray(tasks) || tasks.length === 0) {
alert("No tasks in Archive.");
return;
}
const cutoff = Date.now() - cfg.days * 24 * 60 * 60 * 1000;
const oldTasks = tasks.filter(t => new Date(t.updated).getTime() < cutoff);
if (oldTasks.length === 0) {
alert(`No archived tasks older than ${cfg.days} day(s).`);
return;
}
const list = oldTasks
.map(t => `β€’ [${t.identifier || t.id}] ${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 task of oldTasks) {
try {
await removeTask(task.id);
} catch (e) {
console.error('Failed to delete task', task.id, e);
}
}
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) {
console.error('Failed to move task', t.id, e);
}
}
alert(`Moved ${moved}/${tasks.length} tasks. Reloading...`);
location.reload();
} catch (e) {
alert("Bulk move failed.");
}
}
/* =====================================================================
FILTER SIDEBAR + CTRL+/ LIVE SEARCH
===================================================================== */
const FILTERS_STORAGE_KEY = "kanban_filters_persistent_v2_origin";
const FILTERS_STATE = {
active: false,
sidebar: null,
savedFiltersDiv: null,
addFilterBtn: null,
exportFiltersBtn: null,
importFiltersBtn: null,
filtersFileInput: null,
searchInput: null,
keyHandler: null,
};
function loadFilters() {
try {
return JSON.parse(localStorage.getItem(FILTERS_STORAGE_KEY) || "{}");
} catch (e) {
console.warn("Failed to parse saved filters, resetting.", e);
return {};
}
}
function saveFilters(obj) {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(obj));
}
const getFilterBaseKey = () => window.location.origin;
function ensureFiltersUI() {
if (FILTERS_STATE.sidebar) return;
const sidebar = document.createElement("div");
sidebar.style.cssText = `
position: fixed; top: 0; right: -380px; width: 360px; height: 100%;
background: ${THEME.panel}; color: ${THEME.text}; border-left: 1px solid ${THEME.border};
box-shadow: -4px 0 20px ${THEME.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:${THEME.button}; color:${THEME.buttonText};
border:none; border-radius:6px; cursor:pointer; font-size:13px;
">+ Add Current Filter</button>
<button id="exportFiltersBtn" style="
padding:8px 10px; background:${THEME.button}; color:${THEME.buttonText};
border:none; border-radius:6px; cursor:pointer; font-size:13px;
">⬇️</button>
<button id="importFiltersBtn" style="
padding:8px 10px; background:${THEME.button}; color:${THEME.buttonText};
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). You'll be asked Replace / Merge / Cancel.</div>
<div>Saved value contains only the <code>filter</code> parameter.</div>
</div>
`;
document.body.appendChild(sidebar);
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: ${THEME.bg}; color: ${THEME.text};
border: 2px solid ${THEME.button}; border-radius: 6px; box-shadow: 0 4px 20px ${THEME.shadow};
box-sizing: border-box;
`;
document.body.appendChild(searchInput);
FILTERS_STATE.sidebar = sidebar;
FILTERS_STATE.savedFiltersDiv = sidebar.querySelector("#savedFilters");
FILTERS_STATE.addFilterBtn = sidebar.querySelector("#addFilterBtn");
FILTERS_STATE.exportFiltersBtn = sidebar.querySelector("#exportFiltersBtn");
FILTERS_STATE.importFiltersBtn = sidebar.querySelector("#importFiltersBtn");
FILTERS_STATE.filtersFileInput = sidebar.querySelector("#filtersFileInput");
FILTERS_STATE.searchInput = searchInput;
FILTERS_STATE.addFilterBtn.addEventListener("click", handleAddFilter);
FILTERS_STATE.exportFiltersBtn.addEventListener("click", handleExportFilters);
FILTERS_STATE.importFiltersBtn.addEventListener("click", () => FILTERS_STATE.filtersFileInput.click());
FILTERS_STATE.filtersFileInput.addEventListener("change", handleImportFilters);
FILTERS_STATE.searchInput.addEventListener("input", () => {
if (FILTERS_STATE.active) filterTasks(FILTERS_STATE.searchInput.value);
});
FILTERS_STATE.searchInput.addEventListener("keydown", handleSearchKeydown);
}
function openFiltersSidebar() {
if (!FILTERS_STATE.active) return;
ensureFiltersUI();
renderFilters();
FILTERS_STATE.sidebar.style.right = "0";
}
function closeFiltersSidebar() {
if (FILTERS_STATE.sidebar) FILTERS_STATE.sidebar.style.right = "-380px";
}
function handleAddFilter() {
if (!FILTERS_STATE.active) return;
const title = prompt("Filter title?");
if (title === null) return;
const base = getFilterBaseKey();
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();
}
function handleExportFilters() {
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);
}
async function handleImportFilters() {
try {
const file = FILTERS_STATE.filtersFileInput.files[0];
if (!file) return;
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 {
FILTERS_STATE.filtersFileInput.value = "";
}
}
function mergeFilterStrings(a, b) {
const parts = [];
const 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(" && ");
}
function applyFilterWithPrompt(savedEntry) {
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 = "";
}
}
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;
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 {
if ((saved || "").trim() === "") url.searchParams.delete("filter");
else url.searchParams.set("filter", saved.trim());
}
window.location.href = url.toString();
}
function buildFilterBtnStyle() {
return `
padding:6px; cursor:pointer;
background:${THEME.panel}; color:${THEME.text};
border:1px solid ${THEME.border}; border-radius:6px;
min-width:40px; font-size:13px;
`;
}
function renderFilters() {
if (!FILTERS_STATE.savedFiltersDiv) return;
const all = loadFilters();
const base = getFilterBaseKey();
const list = all[base] || [];
FILTERS_STATE.savedFiltersDiv.innerHTML = "";
if (!Array.isArray(list) || list.length === 0) {
FILTERS_STATE.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:${THEME.bg}; color:${THEME.text};
padding:8px; border:1px solid ${THEME.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 = buildFilterBtnStyle();
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[getFilterBaseKey()] = allData[getFilterBaseKey()] || [];
allData[getFilterBaseKey()][i] = f;
saveFilters(allData);
renderFilters();
};
const copyBtn = document.createElement("button");
copyBtn.textContent = "πŸ“‹";
copyBtn.title = "Copy filter value to clipboard";
copyBtn.style.cssText = buildFilterBtnStyle();
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)";
mergeBtn.style.cssText = buildFilterBtnStyle();
mergeBtn.onclick = () => {
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 = buildFilterBtnStyle();
delBtn.onclick = () => {
if (!confirm(`Delete "${f.title}"?`)) return;
const allData = loadFilters();
const baseKey = getFilterBaseKey();
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);
FILTERS_STATE.savedFiltersDiv.appendChild(row);
});
}
const TASK_SELECTORS = ["li", ".task", ".item", "[data-task]"];
const getTasks = () => 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() {
if (!FILTERS_STATE.searchInput) return;
FILTERS_STATE.searchInput.value = "";
FILTERS_STATE.searchInput.style.display = "none";
filterTasks("");
}
function handleSearchKeydown(e) {
if (e.key === "Enter") {
const visible = getTasks().filter(t => t.style.display !== "none");
if (visible.length === 1) {
visible[0].click();
resetSearch();
}
}
}
function attachFilterKeyHandler() {
if (FILTERS_STATE.keyHandler) return;
FILTERS_STATE.keyHandler = (e) => {
if (!FILTERS_STATE.active) return;
if (e.key === "/" && e.ctrlKey && !e.metaKey && document.activeElement !== FILTERS_STATE.searchInput) {
e.preventDefault();
FILTERS_STATE.searchInput.style.display = "block";
FILTERS_STATE.searchInput.focus();
FILTERS_STATE.searchInput.select();
} else if (e.key === "Escape") {
if (FILTERS_STATE.sidebar?.style.right === "0px" || FILTERS_STATE.sidebar?.style.right === "0") {
closeFiltersSidebar();
} else if (FILTERS_STATE.searchInput?.style.display === "block") {
resetSearch();
}
}
};
document.addEventListener("keydown", FILTERS_STATE.keyHandler);
}
function detachFilterKeyHandler() {
if (!FILTERS_STATE.keyHandler) return;
document.removeEventListener("keydown", FILTERS_STATE.keyHandler);
FILTERS_STATE.keyHandler = null;
}
function startFiltersFeature() {
if (FILTERS_STATE.active) return;
FILTERS_STATE.active = true;
whenReady(() => {
ensureFiltersUI();
attachFilterKeyHandler();
renderFilters();
});
}
function stopFiltersFeature() {
if (!FILTERS_STATE.active) return;
FILTERS_STATE.active = false;
closeFiltersSidebar();
resetSearch();
detachFilterKeyHandler();
}
/* =====================================================================
FEATURE REGISTRATION
===================================================================== */
FeatureManager.registerFeature("autoLabel", {
label: "Auto label by column",
description: "Keep labels in sync with the column a task sits in.",
start: startAutoLabelFeature,
stop: stopAutoLabelFeature,
});
FeatureManager.registerFeature("cleanup", {
label: "Archive cleanup",
description: "Delete archived tasks older than N days.",
start: startCleanupFeature,
stop: stopCleanupFeature,
openPanel: openCleanupPanel,
actionLabel: "Open cleanup panel",
});
FeatureManager.registerFeature("bulkMove", {
label: "Bulk move",
description: "Move every task from one bucket into another.",
start: startBulkMoveFeature,
stop: stopBulkMoveFeature,
openPanel: openBulkPanel,
actionLabel: "Open bulk move panel",
});
FeatureManager.registerFeature("filters", {
label: "Filters & CTRL+/",
description: "Save filter presets and use live task filtering.",
start: startFiltersFeature,
stop: stopFiltersFeature,
openPanel: openFiltersSidebar,
actionLabel: "Open filters",
});
FeatureManager.init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment