Created
December 14, 2025 16:36
-
-
Save malys/34cfe07c83fb804d27087127e2d63149 to your computer and use it in GitHub Desktop.
[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) | |
| // @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