Last active
January 16, 2026 16:40
-
-
Save malys/34cfe07c83fb804d27087127e2d63149 to your computer and use it in GitHub Desktop.
[Kanban vikunja] kanban enhancer #userscript #violentmonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Vikunja Kanban enhancer | |
| // @description Auto Label by Column + Purge Archive + Bulk move + Quick filters (import, export, merge) + Task templates | |
| // @namespace malys | |
| // @version 7.0 | |
| // @match https://TODO/projects/* | |
| // @match https://try.vikunja.io/* | |
| // @grant none | |
| // @downloadURL https://gist.githubusercontent.com/malys/34cfe07c83fb804d27087127e2d63149/raw/userscript.js | |
| // @updateURL https://gist.githubusercontent.com/malys/34cfe07c83fb804d27087127e2d63149/raw/userscript.js | |
| // ==/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 fetchTask(taskId) { | |
| return await fetchJson(`${API_BASE}/tasks/${taskId}`, { | |
| 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; | |
| } | |
| async function createTask(projectId, data) { | |
| return await fetchJson(`${API_BASE}/projects/${projectId}/tasks`, { | |
| method: "PUT", | |
| headers: authHeaders({ "Content-Type": "application/json" }), | |
| body: JSON.stringify(data), | |
| }); | |
| } | |
| function openTask(taskId) { | |
| window.location.href = `${window.location.origin}/tasks/${taskId}`; | |
| } | |
| function getTaskIdFromUrl() { | |
| const m = window.location.pathname.match(/\/tasks\/(\d+)/); | |
| return m ? Number(m[1]) : null; | |
| } | |
| // --------------------------- | |
| // 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(); | |
| } | |
| /* ========================================================= | |
| TASK TEMPLATES FEATURE | |
| ========================================================= */ | |
| /* ========================================================= | |
| TASK TEMPLATES FEATURE (BUCKET-AWARE, EDIT / DELETE) | |
| ========================================================= */ | |
| const TEMPLATES_STORAGE_KEY = "vikunja_task_templates_v2_origin"; | |
| const TEMPLATES_STATE = { | |
| sidebar: null, | |
| listDiv: null, | |
| addBtn: null, | |
| exportBtn: null, | |
| importBtn: null, | |
| fileInput: null, | |
| }; | |
| /* ========================= | |
| STORAGE | |
| ========================= */ | |
| function loadTemplates() { | |
| try { | |
| const all = JSON.parse(localStorage.getItem(TEMPLATES_STORAGE_KEY) || "{}"); | |
| return all[window.location.origin] || []; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveTemplates(list) { | |
| let all = {}; | |
| try { | |
| all = JSON.parse(localStorage.getItem(TEMPLATES_STORAGE_KEY) || "{}"); | |
| } catch {} | |
| all[window.location.origin] = list; | |
| localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(all)); | |
| } | |
| /* ========================= | |
| UI | |
| ========================= */ | |
| function ensureTemplatesUI() { | |
| if (TEMPLATES_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}; | |
| padding: 14px; | |
| z-index: 999998; | |
| transition: right .25s ease; | |
| display: flex; | |
| flex-direction: column; | |
| `; | |
| sidebar.innerHTML = ` | |
| <h3 style="margin:0 0 12px;">Task templates</h3> | |
| <div style="display:flex; gap:8px; margin-bottom:12px;"> | |
| <button id="tplAddBtn">+ New</button> | |
| <button id="tplExportBtn">⬇️</button> | |
| <button id="tplImportBtn">⬆️</button> | |
| </div> | |
| <input type="file" id="tplFileInput" style="display:none" accept="application/json"> | |
| <div id="tplList" style="flex:1; overflow:auto;"></div> | |
| `; | |
| document.body.appendChild(sidebar); | |
| Object.assign(TEMPLATES_STATE, { | |
| sidebar, | |
| listDiv: sidebar.querySelector("#tplList"), | |
| addBtn: sidebar.querySelector("#tplAddBtn"), | |
| exportBtn: sidebar.querySelector("#tplExportBtn"), | |
| importBtn: sidebar.querySelector("#tplImportBtn"), | |
| fileInput: sidebar.querySelector("#tplFileInput"), | |
| }); | |
| TEMPLATES_STATE.addBtn.onclick = () => openTemplateForm(); | |
| TEMPLATES_STATE.exportBtn.onclick = exportTemplates; | |
| TEMPLATES_STATE.importBtn.onclick = () => TEMPLATES_STATE.fileInput.click(); | |
| TEMPLATES_STATE.fileInput.onchange = importTemplates; | |
| } | |
| /* ========================= | |
| CREATE / EDIT FORM | |
| ========================= */ | |
| async function openTemplateForm(existing = null) { | |
| let prefill = existing; | |
| if (!existing) { | |
| const taskId = getTaskIdFromUrl(); | |
| if (taskId) { | |
| try { | |
| const task = await fetchTask(taskId); | |
| prefill = { | |
| title: task.title, | |
| task: { | |
| title: task.title, | |
| description: tiptapHTMLToTemplateText(task.description), | |
| labels: [], // optional later | |
| } | |
| }; | |
| } catch (e) { | |
| console.warn("Could not load task for template", e); | |
| } | |
| } | |
| } | |
| ensureTemplatesUI(); | |
| const form = document.createElement("div"); | |
| form.style.cssText = ` | |
| border:1px solid ${THEME.border}; | |
| border-radius:8px; | |
| padding:12px; | |
| background:${THEME.bg}; | |
| margin-bottom:12px; | |
| display:flex; | |
| flex-direction:column; | |
| gap:8px; | |
| `; | |
| form.innerHTML = ` | |
| <input id="tplName" placeholder="Template name" | |
| value="${prefill?.title || ""}" | |
| style="padding:8px; border-radius:6px; border:1px solid ${THEME.border};"> | |
| <input id="tplTitle" placeholder="Task title" | |
| value="${prefill?.task.title || ""}" | |
| style="padding:8px; border-radius:6px; border:1px solid ${THEME.border};"> | |
| <textarea id="tplDesc" rows="6" | |
| placeholder="Task description (multiline)" | |
| style="padding:8px; border-radius:6px; border:1px solid ${THEME.border}; resize:vertical;">${prefill?.task.description || ""}</textarea> | |
| <input id="tplLabels" placeholder="Labels (comma separated)" | |
| value="${prefill?.task.labels?.join(", ") || ""}" | |
| style="padding:8px; border-radius:6px; border:1px solid ${THEME.border};"> | |
| <button id="tplSaveBtn" | |
| style="margin-top:6px; padding:10px; | |
| border:none; border-radius:6px; | |
| background:${THEME.button}; | |
| color:${THEME.buttonText}; | |
| cursor:pointer;"> | |
| 💾 ${existing ? "Update" : "Save"} template | |
| </button> | |
| `; | |
| form.querySelector("#tplSaveBtn").onclick = () => { | |
| const name = form.querySelector("#tplName").value.trim(); | |
| if (!name) return alert("Template name required."); | |
| const list = loadTemplates(); | |
| const tpl = { | |
| id: existing?.id || crypto.randomUUID(), | |
| title: name, | |
| task: { | |
| title: form.querySelector("#tplTitle").value || "", | |
| description: form.querySelector("#tplDesc").value | |
| .replace(/\r\n/g, "\n") | |
| .replace(/\n{2,}/g, "\n\n") // preserve paragraphs | |
| .replace(/(?<!\n)\n(?!\n)/g, " \n"), // single line breaks | |
| labels: form.querySelector("#tplLabels").value | |
| .split(",").map(s => s.trim()).filter(Boolean), | |
| } | |
| }; | |
| const updated = existing | |
| ? list.map(t => t.id === existing.id ? tpl : t) | |
| : [...list, tpl]; | |
| saveTemplates(updated); | |
| renderTemplates(); | |
| form.remove(); | |
| }; | |
| TEMPLATES_STATE.listDiv.prepend(form); | |
| } | |
| /* ========================= | |
| RENDER LIST | |
| ========================= */ | |
| function renderTemplates() { | |
| const list = loadTemplates(); | |
| const div = TEMPLATES_STATE.listDiv; | |
| div.innerHTML = ""; | |
| if (!list.length) { | |
| div.innerHTML = `<div style="opacity:.6;">No templates</div>`; | |
| return; | |
| } | |
| list.forEach(tpl => { | |
| const row = document.createElement("div"); | |
| row.style.cssText = ` | |
| padding:10px; | |
| border:1px solid ${THEME.border}; | |
| border-radius:6px; | |
| margin-bottom:10px; | |
| background:${THEME.bg}; | |
| `; | |
| row.innerHTML = ` | |
| <div style="font-weight:600; margin-bottom:6px;">${tpl.title}</div> | |
| <div style="display:flex; gap:8px;"> | |
| <button>Use</button> | |
| <button>Edit</button> | |
| <button style="color:red;">Delete</button> | |
| </div> | |
| `; | |
| const [useBtn, editBtn, delBtn] = row.querySelectorAll("button"); | |
| useBtn.onclick = () => applyTemplate(tpl); | |
| editBtn.onclick = () => openTemplateForm(tpl); | |
| delBtn.onclick = () => { | |
| if (confirm(`Delete template "${tpl.title}"?`)) { | |
| saveTemplates(loadTemplates().filter(t => t.id !== tpl.id)); | |
| renderTemplates(); | |
| } | |
| }; | |
| div.appendChild(row); | |
| }); | |
| } | |
| /* ========================= | |
| APPLY TEMPLATE | |
| ========================= */ | |
| function tiptapHTMLToTemplateText(html) { | |
| // Parse HTML | |
| const doc = new DOMParser().parseFromString(html, "text/html"); | |
| const out = []; | |
| // TipTap editor root | |
| const editor = doc.querySelector(".ProseMirror, .tiptap__editor") || doc.body; | |
| for (const node of editor.children) { | |
| // Checklist item | |
| if (node.tagName === "UL" && node.dataset.type === "taskList") { | |
| const p = node.querySelector("p"); | |
| if (p) out.push(`[] ${p.textContent.trim()}`); | |
| } | |
| // Paragraph | |
| else if (node.tagName === "P") { | |
| const text = node.textContent.trim(); | |
| if (text) out.push(text); | |
| } | |
| // Nested divs with paragraphs (sometimes TipTap wraps <p> in <div>) | |
| else if (node.tagName === "DIV") { | |
| const ps = node.querySelectorAll("p"); | |
| ps.forEach(p => { | |
| const text = p.textContent.trim(); | |
| if (text) out.push(text); | |
| }); | |
| } | |
| } | |
| return out.join("\n"); | |
| } | |
| function templateTextToTipTapHTML(text) { | |
| const lines = text | |
| .replace(/\r\n/g, "\n") | |
| .split("\n"); | |
| let html = ""; | |
| for (const rawLine of lines) { | |
| const line = rawLine.trim(); | |
| if (!line) continue; | |
| // Checklist item: [] something | |
| if (line.startsWith("[]")) { | |
| const content = line.slice(2).trim(); | |
| const id = Math.random().toString(36).slice(2, 10); | |
| html += ` | |
| <ul data-type="taskList"> | |
| <li data-type="taskItem" data-checked="false" data-task-id="${id}"> | |
| <label contenteditable="false"> | |
| <input type="checkbox"><span></span> | |
| </label> | |
| <div><p>${escapeHtml(content)}</p></div> | |
| </li> | |
| </ul>`; | |
| } | |
| // Normal paragraph | |
| else { | |
| html += `<p>${escapeHtml(line)}</p>`; | |
| } | |
| } | |
| return html || "<p></p>"; | |
| } | |
| function escapeHtml(str) { | |
| return str.replace(/[&<>"']/g, c => ({ | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| '"': """, | |
| "'": "'", | |
| }[c])); | |
| } | |
| async function applyTemplate(template) { | |
| const pv = getProjectAndViewFromUrl(); | |
| if (!pv) return alert("Not in project view."); | |
| const { projectId, viewId } = pv; | |
| const task = await createTask(projectId, { | |
| title: template.task.title, | |
| description: templateTextToTipTapHTML(template.task.description), | |
| bucket_id: null, | |
| }); | |
| if (template.task.labels?.length) { | |
| const labels = await getAllLabels(); | |
| for (const name of template.task.labels) { | |
| const l = labels.find(x => normalize(x.title) === normalize(name)); | |
| if (l) await addLabelToTask(task.id, l.id); | |
| } | |
| } | |
| openTask(task.id); | |
| } | |
| /* ========================= | |
| EXPORT / IMPORT | |
| ========================= */ | |
| function exportTemplates() { | |
| const blob = new Blob( | |
| [JSON.stringify(loadTemplates(), null, 2)], | |
| { type: "application/json" } | |
| ); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = "vikunja-task-templates.json"; | |
| a.click(); | |
| } | |
| async function importTemplates() { | |
| const file = TEMPLATES_STATE.fileInput.files[0]; | |
| if (!file) return; | |
| const json = JSON.parse(await file.text()); | |
| if (!Array.isArray(json)) return alert("Invalid file"); | |
| saveTemplates(json); | |
| renderTemplates(); | |
| } | |
| /* ========================= | |
| ESCAPE from TEMPLATE panel | |
| ========================= */ | |
| function installTemplatesEscapeHandler() { | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key !== "Escape") return; | |
| if (!TEMPLATES_STATE.sidebar) return; | |
| const isOpen = TEMPLATES_STATE.sidebar.style.right === "0px" | |
| || TEMPLATES_STATE.sidebar.style.right === "0"; | |
| if (isOpen) { | |
| TEMPLATES_STATE.sidebar.style.right = "-380px"; | |
| } | |
| }); | |
| } | |
| /* ===================================================================== | |
| 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.registerFeature("templates", { | |
| label: "Task templates", | |
| description: "Create tasks from reusable templates.", | |
| actionLabel: "Open templates", | |
| start() { | |
| TEMPLATES_STATE.active = true; | |
| ensureTemplatesUI(); | |
| renderTemplates(); | |
| installTemplatesEscapeHandler(); | |
| }, | |
| stop() { | |
| TEMPLATES_STATE.active = false; | |
| if (TEMPLATES_STATE.sidebar){ | |
| TEMPLATES_STATE.sidebar.style.right = "-380px"; | |
| } | |
| }, | |
| openPanel() { | |
| ensureTemplatesUI(); | |
| renderTemplates(); | |
| TEMPLATES_STATE.sidebar.style.right = "0"; | |
| }, | |
| }); | |
| FeatureManager.init(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment