Skip to content

Instantly share code, notes, and snippets.

@Yandrik
Last active August 22, 2025 07:38
Show Gist options
  • Save Yandrik/9a5329575db39b78d5a61a63c858218a to your computer and use it in GitHub Desktop.
Save Yandrik/9a5329575db39b78d5a61a63c858218a to your computer and use it in GitHub Desktop.
Vikunja Quick Move / Delete Userscript
// ==UserScript==
// @name Vikunja Quick Project Switch (try.vikunja.io)
// @namespace http://tampermonkey.net/
// @version 1.3
// @description Adds a button to quickly move tasks to other projects, add tags, and delete tasks in Vikunja.
// @author Yandrik
// @match https://try.vikunja.io/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// ==/UserScript==
// USAGE:
// install in Tampermonkey (create new, copy -> paste)
// There, in the editor:
// ENTER YOUR VICUNJA INSTANCE URL IN @match!
(function() {
'use strict';
const debug = false;
let projects = [];
let allLabels = [];
// Function to log messages if debug is true
function log(...messages) {
if (debug) {
console.log('[Vikunja Quick Switch]', ...messages);
}
}
// Function to get the JWT token from local storage
function getJwtToken() {
return localStorage.getItem('token');
}
// Function to fetch all projects
function fetchProjects() {
log('fetchProjects');
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: '/api/v1/projects',
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status === 200) {
projects = JSON.parse(response.responseText);
log('Projects loaded:', projects);
} else {
log('Failed to fetch projects:', response.statusText);
}
},
onerror: function(error) {
log('Error fetching projects:', error);
}
});
}
// Function to fetch all labels
function fetchAllLabels() {
log('fetchAllLabels');
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return Promise.resolve([]);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: '/api/v1/labels',
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status === 200) {
allLabels = JSON.parse(response.responseText);
log('All labels loaded:', allLabels);
resolve(allLabels);
} else {
log('Failed to fetch labels:', response.statusText);
resolve([]);
}
},
onerror: function(error) {
log('Error fetching labels:', error);
reject(error);
}
});
});
}
// Function to fetch labels for a specific task
function fetchTaskLabels(taskId) {
log('fetchTaskLabels', taskId);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return Promise.resolve([]);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `/api/v1/tasks/${taskId}/labels`,
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status === 200) {
const taskLabels = JSON.parse(response.responseText);
log('Task labels loaded:', taskLabels);
resolve(taskLabels);
} else {
log('Failed to fetch task labels:', response.statusText);
resolve([]);
}
},
onerror: function(error) {
log('Error fetching task labels:', error);
reject(error);
}
});
});
}
const COLORS = [
'#ffbe0b',
'#fd8a09',
'#fb5607',
'#ff006e',
'#efbdeb',
'#8338ec',
'#5f5ff6',
'#3a86ff',
'#4c91ff',
'#0ead69',
'#25be8b',
'#073b4c',
'#373f47',
];
function getRandomColorHex() {
return COLORS[Math.floor(Math.random() * COLORS.length)];
}
// Function to create a new label
function createLabel(title, hexColor = null) {
if (!hexColor) {
hexColor = getRandomColorHex().replace('#', '');
}
log('createLabel', title, hexColor);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PUT',
url: '/api/v1/labels',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: JSON.stringify({
title: title,
hex_color: hexColor
}),
onload: function(response) {
if (response.status === 200 || response.status === 201) {
const newLabel = JSON.parse(response.responseText);
log('Label created:', newLabel);
allLabels.push(newLabel);
resolve(newLabel);
} else {
log('Failed to create label:', response.statusText);
resolve(null);
}
},
onerror: function(error) {
log('Error creating label:', error);
reject(error);
}
});
});
}
// Function to add a label to a task
function addLabelToTask(taskId, labelId) {
log('addLabelToTask', taskId, labelId);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return Promise.resolve(false);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PUT',
url: `/api/v1/tasks/${taskId}/labels`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: JSON.stringify({
label_id: labelId
}),
onload: function(response) {
if (response.status === 200 || response.status === 201) {
log('Label added to task successfully');
resolve(true);
} else {
log('Failed to add label to task:', response.statusText);
resolve(false);
}
},
onerror: function(error) {
log('Error adding label to task:', error);
reject(error);
}
});
});
}
// Function to remove a label from a task
function removeLabelFromTask(taskId, labelId) {
log('removeLabelFromTask', taskId, labelId);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return Promise.resolve(false);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'DELETE',
url: `/api/v1/tasks/${taskId}/labels/${labelId}`,
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status === 200 || response.status === 204) {
log('Label removed from task successfully');
resolve(true);
} else {
log('Failed to remove label from task:', response.statusText);
resolve(false);
}
},
onerror: function(error) {
log('Error removing label from task:', error);
reject(error);
}
});
});
}
// Function to perform fuzzy search on labels
function fuzzySearch(needle, haystack) {
if (!needle) return true;
const needleLower = needle.toLowerCase();
const haystackLower = haystack.toLowerCase();
// Simple fuzzy search: check if all characters from needle exist in order in haystack
let needleIndex = 0;
for (let i = 0; i < haystackLower.length && needleIndex < needleLower.length; i++) {
if (haystackLower[i] === needleLower[needleIndex]) {
needleIndex++;
}
}
return needleIndex === needleLower.length;
}
// Function to get text color based on background color
function getTextColor(hexColor) {
// Remove # if present
const color = hexColor.replace('#', '');
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return dark or light text color based on background luminance
return luminance > 0.5 ? 'hsl(215, 27.9%, 16.9%)' : 'hsl(220, 14.3%, 95.9%)';
}
// Function to create and show the label selector
async function showLabelSelector(event, taskElement) {
const existingSelector = document.getElementById('label-quick-selector');
if (existingSelector) {
existingSelector.remove();
}
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
// Fetch data in parallel - always refresh labels
const [taskLabels] = await Promise.all([
fetchTaskLabels(taskId),
fetchAllLabels() // Always fetch fresh labels
]);
const selectorContainer = document.createElement('div');
selectorContainer.id = 'label-quick-selector';
selectorContainer.className = 'multiselect has-search-results';
selectorContainer.style.position = 'absolute';
selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
selectorContainer.style.left = `${window.scrollX + rect.right - 350}px`; // Position to the left of the button
selectorContainer.style.width = '350px';
selectorContainer.style.zIndex = '10000';
selectorContainer.tabIndex = -1;
const controlDiv = document.createElement('div');
controlDiv.className = 'control';
const inputWrapper = document.createElement('div');
inputWrapper.className = 'input-wrapper input has-multiple';
// Add current task labels as chips
(taskLabels || []).forEach(label => {
const chip = createLabelChip(label, taskId, () => {
// Refresh the selector after removing a label, but don't close it
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
});
inputWrapper.appendChild(chip);
});
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'input';
searchInput.placeholder = 'Type to add a label…';
searchInput.style.border = 'none';
searchInput.style.outline = 'none';
searchInput.style.backgroundColor = 'transparent';
inputWrapper.appendChild(searchInput);
controlDiv.appendChild(inputWrapper);
selectorContainer.appendChild(controlDiv);
const searchResults = document.createElement('div');
searchResults.className = 'search-results';
selectorContainer.appendChild(searchResults);
let selectedIndex = -1;
function updateSearchResults(query = '', currentTaskLabels = taskLabels) {
searchResults.innerHTML = '';
selectedIndex = -1;
// Filter available labels (excluding already attached ones) - use actual task labels data
const attachedLabelTitles = (currentTaskLabels || []).map(l => l.title);
const availableLabels = allLabels.filter(label =>
!attachedLabelTitles.includes(label.title) &&
fuzzySearch(query, label.title)
);
// Show matching existing labels
availableLabels.forEach((label, index) => {
const resultButton = document.createElement('button');
resultButton.type = 'button';
resultButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
const labelSpan = document.createElement('span');
const labelChip = document.createElement('span');
labelChip.className = 'tag search-result';
labelChip.style.background = `#${label.hex_color}`;
labelChip.style.color = getTextColor(label.hex_color);
const labelText = document.createElement('span');
labelText.textContent = label.title;
labelChip.appendChild(labelText);
labelSpan.appendChild(labelChip);
const hintText = document.createElement('span');
hintText.className = 'hint-text';
hintText.textContent = 'Click or press enter to select';
resultButton.appendChild(labelSpan);
resultButton.appendChild(hintText);
resultButton.addEventListener('click', async () => {
await addLabelToTask(taskId, label.id);
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
});
searchResults.appendChild(resultButton);
});
// Show "Add as new label" option if query exists and no exact match among ALL labels (not just available ones)
if (query.trim() && !allLabels.some(l => l.title.toLowerCase() === query.toLowerCase())) {
const createButton = document.createElement('button');
createButton.type = 'button';
createButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
const labelSpan = document.createElement('span');
const labelChip = document.createElement('span');
labelChip.className = 'tag search-result';
const labelText = document.createElement('span');
labelText.textContent = query.trim();
labelChip.appendChild(labelText);
labelSpan.appendChild(labelChip);
const hintText = document.createElement('span');
hintText.className = 'hint-text';
hintText.textContent = 'Add this as new label';
createButton.appendChild(labelSpan);
createButton.appendChild(hintText);
createButton.addEventListener('click', async () => {
const newLabel = await createLabel(query.trim());
if (newLabel) {
await addLabelToTask(taskId, newLabel.id);
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
}
});
searchResults.appendChild(createButton);
}
}
// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
const results = searchResults.querySelectorAll('.search-result-button');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
updateSelection(results);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(results);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
results[selectedIndex].click();
} else if (searchInput.value.trim()) {
const query = searchInput.value.trim();
// Check if there's an exact match with an existing label first
const attachedLabelTitles = (taskLabels || []).map(l => l.title);
const exactMatch = allLabels.find(label =>
!attachedLabelTitles.includes(label.title) &&
label.title.toLowerCase() === query.toLowerCase()
);
if (exactMatch) {
// Use existing label
addLabelToTask(taskId, exactMatch.id).then(() => {
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
});
} else {
// Create new label only if no exact match exists
createLabel(query).then(newLabel => {
if (newLabel) {
return addLabelToTask(taskId, newLabel.id);
}
}).then(() => {
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
});
}
}
} else if (e.key === 'Escape') {
selectorContainer.remove();
}
});
function updateSelection(results) {
results.forEach((result, index) => {
if (index === selectedIndex) {
result.style.backgroundColor = 'var(--grey-200, #f1f3f9)';
} else {
result.style.backgroundColor = '';
}
});
}
searchInput.addEventListener('input', (e) => {
const currentTaskLabels = e.taskLabels || taskLabels;
updateSearchResults(e.target.value, currentTaskLabels);
});
updateSearchResults();
document.body.appendChild(selectorContainer);
// Focus the input
searchInput.focus();
// Close selector when clicking outside
document.addEventListener('click', function closeSelector(e) {
if (!selectorContainer.contains(e.target) && e.target !== button) {
selectorContainer.remove();
document.removeEventListener('click', closeSelector);
}
});
}
// Function to update the task's label display in the task list
function updateTaskLabelsDisplay(taskElement, taskLabels) {
let labelWrapper = taskElement.querySelector('.label-wrapper.labels');
// If no label wrapper exists and we have labels to display, create it
if (!labelWrapper && taskLabels && taskLabels.length > 0) {
labelWrapper = document.createElement('div');
labelWrapper.className = 'label-wrapper labels ml-2 mr-1';
// Add Vue data attributes by copying from existing elements if available
const existingLabelWrapper = document.querySelector('.label-wrapper.labels');
if (existingLabelWrapper) {
Array.from(existingLabelWrapper.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
labelWrapper.setAttribute(attr.name, attr.value);
}
});
}
// Insert the label wrapper after the task link span
const taskTextDiv = taskElement.querySelector('.tasktext');
const taskLinkSpan = taskTextDiv.querySelector('span');
if (taskLinkSpan && taskTextDiv) {
taskTextDiv.insertBefore(labelWrapper, taskLinkSpan.nextSibling);
} else {
log('Could not find proper insertion point for label wrapper');
return;
}
}
// If still no label wrapper and no labels to display, nothing to do
if (!labelWrapper) {
return;
}
// Get the Vue data attributes from an existing label if any exist
const existingLabel = labelWrapper.querySelector('.tag');
let outerDataAttrs = '';
let innerDataAttrs = '';
if (existingLabel) {
// Extract data attributes from existing labels
const outerAttrs = Array.from(existingLabel.attributes)
.filter(attr => attr.name.startsWith('data-v-'))
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ');
const innerSpan = existingLabel.querySelector('span');
const innerAttrs = innerSpan ? Array.from(innerSpan.attributes)
.filter(attr => attr.name.startsWith('data-v-'))
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ') : '';
outerDataAttrs = outerAttrs;
innerDataAttrs = innerAttrs;
}
// Clear existing labels
labelWrapper.innerHTML = '';
// Add updated labels with proper Vue data attributes
(taskLabels || []).forEach(label => {
const labelSpan = document.createElement('span');
labelSpan.className = 'tag';
labelSpan.style.background = `#${label.hex_color}`;
labelSpan.style.color = getTextColor(label.hex_color);
labelSpan.style.marginRight = '0.25rem'; // Add spacing between labels
// Add Vue data attributes for proper styling
if (outerDataAttrs) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `<span class="tag" ${outerDataAttrs}></span>`;
const tempSpan = tempDiv.firstChild;
Array.from(tempSpan.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
labelSpan.setAttribute(attr.name, attr.value);
}
});
}
const labelText = document.createElement('span');
labelText.textContent = label.title;
// Add Vue data attributes to inner span
if (innerDataAttrs) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `<span ${innerDataAttrs}></span>`;
const tempSpan = tempDiv.firstChild;
Array.from(tempSpan.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
labelText.setAttribute(attr.name, attr.value);
}
});
}
labelSpan.appendChild(labelText);
labelWrapper.appendChild(labelSpan);
});
}
// Function to refresh the label selector without closing it
async function refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement = null) {
// Fetch fresh data
const [taskLabels] = await Promise.all([
fetchTaskLabels(taskId),
fetchAllLabels() // Always refresh all labels too
]);
const inputWrapper = selectorContainer.querySelector('.input-wrapper');
// Clear existing chips (but keep the input)
const existingChips = inputWrapper.querySelectorAll('.tag');
existingChips.forEach(chip => chip.remove());
// Re-add current task labels as chips
(taskLabels || []).forEach(label => {
const chip = createLabelChip(label, taskId, () => {
refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
});
inputWrapper.insertBefore(chip, searchInput);
});
// Update the task's label display in the task list
if (taskElement) {
updateTaskLabelsDisplay(taskElement, taskLabels);
}
// Clear search input and trigger update to refresh the search results with fresh task labels
searchInput.value = '';
// Manually call updateSearchResults with the fresh task labels
const updateEvent = new Event('input');
updateEvent.taskLabels = taskLabels;
searchInput.dispatchEvent(updateEvent);
searchInput.focus();
}
// Function to create a label chip
function createLabelChip(label, taskId, onRemove) {
const chip = document.createElement('span');
chip.className = 'tag';
chip.style.background = `#${label.hex_color}`;
chip.style.color = getTextColor(label.hex_color);
chip.style.margin = '0 0 .25rem .5rem';
const labelText = document.createElement('span');
labelText.textContent = label.title;
chip.appendChild(labelText);
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'base-button base-button--type-button delete is-small';
deleteButton.addEventListener('click', async (e) => {
e.stopPropagation();
await removeLabelFromTask(taskId, label.id);
if (onRemove) onRemove();
});
chip.appendChild(deleteButton);
return chip;
}
// Function to update the task's priority display in the task list
function updateTaskPriorityDisplay(taskElement, priority) {
const taskTextDiv = taskElement.querySelector('.tasktext');
if (!taskTextDiv) {
log('Could not find tasktext div for priority update');
return;
}
const taskSpan = taskTextDiv.querySelector('span');
if (!taskSpan) {
log('Could not find task span for priority update');
return;
}
// Remove existing priority indicator if it exists
const existingPriorityLabel = taskSpan.querySelector('.priority-label');
if (existingPriorityLabel) {
existingPriorityLabel.remove();
}
// Only show priority indicators for priorities 3, 4, and 5
if (priority >= 3 && priority <= 5) {
// Get Vue data attributes from existing priority elements if available
const existingPriority = document.querySelector('.priority-label');
let outerDataAttrs = '';
let iconDataAttrs = '';
let textDataAttrs = '';
if (existingPriority) {
// Extract data attributes from existing priority elements
const outerAttrs = Array.from(existingPriority.attributes)
.filter(attr => attr.name.startsWith('data-v-'))
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ');
const iconSpan = existingPriority.querySelector('.icon');
const iconAttrs = iconSpan ? Array.from(iconSpan.attributes)
.filter(attr => attr.name.startsWith('data-v-'))
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ') : '';
const textSpan = existingPriority.querySelector('span:not(.icon)');
const textAttrs = textSpan ? Array.from(textSpan.attributes)
.filter(attr => attr.name.startsWith('data-v-'))
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ') : '';
outerDataAttrs = outerAttrs;
iconDataAttrs = iconAttrs;
textDataAttrs = textAttrs;
}
// Create priority indicator
const prioritySpan = document.createElement('span');
// Set classes based on priority level
if (priority === 3) {
prioritySpan.className = 'not-so-high high-priority priority-label pr-2';
} else {
prioritySpan.className = 'high-priority priority-label pr-2';
}
// Add Vue data attributes if available
if (outerDataAttrs) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `<span ${outerDataAttrs}></span>`;
const tempSpan = tempDiv.firstChild;
Array.from(tempSpan.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
prioritySpan.setAttribute(attr.name, attr.value);
}
});
}
// Create icon span
const iconSpan = document.createElement('span');
iconSpan.className = 'icon';
// Add Vue data attributes to icon span
if (iconDataAttrs) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `<span ${iconDataAttrs}></span>`;
const tempSpan = tempDiv.firstChild;
Array.from(tempSpan.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
iconSpan.setAttribute(attr.name, attr.value);
}
});
}
// Create SVG icon (fa-circle-exclamation)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'svg-inline--fa fa-circle-exclamation');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('data-prefix', 'fas');
svg.setAttribute('data-icon', 'circle-exclamation');
svg.setAttribute('role', 'img');
svg.setAttribute('viewBox', '0 0 512 512');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', '');
path.setAttribute('fill', 'currentColor');
path.setAttribute('d', 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z');
svg.appendChild(path);
iconSpan.appendChild(svg);
prioritySpan.appendChild(iconSpan);
// Create text span
const textSpan = document.createElement('span');
// Add Vue data attributes to text span
if (textDataAttrs) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = `<span ${textDataAttrs}></span>`;
const tempSpanTemp = tempDiv.firstChild;
Array.from(tempSpanTemp.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
textSpan.setAttribute(attr.name, attr.value);
}
});
}
// Set text based on priority level
if (priority === 3) {
textSpan.textContent = 'High';
} else if (priority === 4) {
textSpan.textContent = 'Urgent';
} else if (priority === 5) {
textSpan.textContent = 'DO NOW';
}
prioritySpan.appendChild(textSpan);
// Insert the priority indicator before the task link
const taskLink = taskSpan.querySelector('a.task-link');
if (taskLink) {
taskSpan.insertBefore(prioritySpan, taskLink);
} else {
// Fallback: prepend to the span
taskSpan.insertBefore(prioritySpan, taskSpan.firstChild);
}
}
}
// Function to update task priority
function updateTaskPriority(taskId, priority, taskElement) {
log('updateTaskPriority', taskId, priority, taskElement);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return;
}
// First, fetch the current task details
GM_xmlhttpRequest({
method: 'GET',
url: `/api/v1/tasks/${taskId}`,
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status !== 200) {
log('Failed to fetch task details:', response.statusText);
alert('Failed to fetch task details before updating priority.');
return;
}
const task = JSON.parse(response.responseText);
task.priority = priority;
log('task to be updated with priority:', task, 'priority:', priority);
// Now, update the task with the new priority
GM_xmlhttpRequest({
method: 'POST',
url: `/api/v1/tasks/${taskId}`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: JSON.stringify(task),
onload: function(response) {
if (response.status === 200) {
log(`Task ${taskId} priority updated to ${priority}`);
// Update the task's priority display in the HTML
updateTaskPriorityDisplay(taskElement, priority);
} else {
log(`Failed to update task priority:`, response);
alert('Failed to update the task priority. Please try again.');
}
},
onerror: function(error) {
log('Error updating task priority:', error);
alert('An error occurred while updating the task priority.');
}
});
},
onerror: function(error) {
log('Error fetching task details:', error);
alert('An error occurred while fetching task details.');
}
});
}
// Function to move a task to a different project
function moveTask(taskId, projectId, taskElement) {
log('moveTask', taskId, projectId, taskElement);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return;
}
// First, fetch the current task details
GM_xmlhttpRequest({
method: 'GET',
url: `/api/v1/tasks/${taskId}`,
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status !== 200) {
log('Failed to fetch task details:', response.statusText);
alert('Failed to fetch task details before moving.');
return;
}
const task = JSON.parse(response.responseText);
task.project_id = projectId;
log('task to be updated:', task, 'projID:', projectId);
// Now, update the task with the new project_id
GM_xmlhttpRequest({
method: 'POST',
url: `/api/v1/tasks/${taskId}`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: JSON.stringify(task),
onload: function(response) {
if (response.status === 200) {
log(`Task ${taskId} moved to project ${projectId}`);
// Remove the task from the list only if we're in a specific project view with a real project ID
const match = window.location.pathname.match(/\/projects\/(\d+)/);
if (match && parseInt(match[1]) >= 0) {
taskElement.remove();
} else {
// Update the project link and text if task remains visible
const projectLink = taskElement.querySelector('.task-project');
if (projectLink) {
const targetProject = projects.find(p => p.id === projectId);
if (targetProject) {
projectLink.href = `/projects/${projectId}`;
projectLink.textContent = targetProject.title;
}
}
}
} else {
log(`Failed to move task:`);
log(response);
alert('Failed to move the task. Please try again.');
}
},
onerror: function(error) {
log('Error moving task:', error);
alert('An error occurred while moving the task.');
}
});
},
onerror: function(error) {
log('Error fetching task details:', error);
alert('An error occurred while fetching task details.');
}
});
}
// Function to delete a task
function deleteTask(taskId, taskElement) {
log('deleteTask', taskId);
const token = getJwtToken();
if (!token) {
log('JWT Token not found.');
return;
}
GM_xmlhttpRequest({
method: 'DELETE',
url: `/api/v1/tasks/${taskId}`,
headers: {
'Authorization': `Bearer ${token}`
},
onload: function(response) {
if (response.status === 204 || response.status === 200) { // Successfully deleted
log(`Task ${taskId} deleted`);
taskElement.remove();
} else {
log(`Failed to delete task:`, response);
alert('Failed to delete the task. Please try again.');
}
},
onerror: function(error) {
log('Error deleting task:', error);
alert('An error occurred while deleting the task.');
}
});
}
// Function to create and show the priority selector
function showPrioritySelector(event, taskElement) {
const existingSelector = document.getElementById('priority-quick-selector');
if (existingSelector) {
existingSelector.remove();
}
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
const selectorContainer = document.createElement('div');
selectorContainer.id = 'priority-quick-selector';
selectorContainer.className = 'priority-selector';
selectorContainer.style.position = 'absolute';
selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
selectorContainer.style.left = `${window.scrollX + rect.right - 200}px`; // Position to the left of the button
selectorContainer.style.width = '200px';
selectorContainer.style.zIndex = '10000';
selectorContainer.style.backgroundColor = 'var(--white, #fff)';
selectorContainer.style.border = '1px solid var(--primary, #1FB6F6)';
selectorContainer.style.borderRadius = '4px';
selectorContainer.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
const priorities = [
{ value: 0, label: 'Unset', color: '#6b7280' },
{ value: 1, label: 'Low', color: '#10b981' },
{ value: 2, label: 'Medium', color: '#f59e0b' },
{ value: 3, label: 'High', color: '#ef4444' },
{ value: 4, label: 'Urgent', color: '#dc2626' },
{ value: 5, label: 'DO NOW!', color: '#991b1b' }
];
priorities.forEach((priority, index) => {
const priorityButton = document.createElement('button');
priorityButton.type = 'button';
priorityButton.className = 'priority-option';
priorityButton.style.width = '100%';
priorityButton.style.display = 'flex';
priorityButton.style.alignItems = 'center';
priorityButton.style.padding = '.75rem';
priorityButton.style.border = 'none';
priorityButton.style.backgroundColor = 'transparent';
priorityButton.style.textAlign = 'left';
priorityButton.style.cursor = 'pointer';
priorityButton.style.transition = 'background-color .15s ease';
if (index > 0) {
priorityButton.style.borderTop = '1px solid var(--grey-200, #f1f3f9)';
}
const priorityIndicator = document.createElement('span');
priorityIndicator.className = 'priority-indicator';
priorityIndicator.style.display = 'inline-block';
priorityIndicator.style.width = '12px';
priorityIndicator.style.height = '12px';
priorityIndicator.style.borderRadius = '50%';
priorityIndicator.style.backgroundColor = priority.color;
priorityIndicator.style.marginRight = '0.75rem';
priorityIndicator.style.flexShrink = '0';
const priorityText = document.createElement('span');
priorityText.textContent = priority.label;
priorityText.style.fontWeight = '500';
priorityButton.appendChild(priorityIndicator);
priorityButton.appendChild(priorityText);
priorityButton.addEventListener('mouseenter', () => {
priorityButton.style.backgroundColor = 'var(--grey-200, #f1f3f9)';
});
priorityButton.addEventListener('mouseleave', () => {
priorityButton.style.backgroundColor = 'transparent';
});
priorityButton.addEventListener('click', () => {
updateTaskPriority(taskId, priority.value, taskElement);
selectorContainer.remove();
});
selectorContainer.appendChild(priorityButton);
});
document.body.appendChild(selectorContainer);
// Close the selector if clicking outside
document.addEventListener('click', function closeSelector(e) {
if (!selectorContainer.contains(e.target) && e.target !== button) {
selectorContainer.remove();
document.removeEventListener('click', closeSelector);
}
});
// Close on escape key
document.addEventListener('keydown', function closeOnEscape(e) {
if (e.key === 'Escape') {
selectorContainer.remove();
document.removeEventListener('keydown', closeOnEscape);
}
});
}
// Function to create and show the project selection combobox
function showProjectSelector(event, taskElement) {
const existingSelector = document.getElementById('project-quick-switcher');
if (existingSelector) {
existingSelector.remove();
}
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const selectorContainer = document.createElement('div');
selectorContainer.id = 'project-quick-switcher';
selectorContainer.className = 'project-selector';
selectorContainer.style.position = 'absolute';
selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
selectorContainer.style.left = `${window.scrollX + rect.left}px`;
selectorContainer.style.width = '250px';
selectorContainer.style.zIndex = '10000';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'project-search-input';
searchInput.placeholder = 'Search projects...';
selectorContainer.appendChild(searchInput);
const projectList = document.createElement('ul');
projectList.className = 'project-list';
function populateProjectList(filter = '') {
projectList.innerHTML = '';
const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(filter.toLowerCase()));
filteredProjects.forEach(project => {
const listItem = document.createElement('li');
listItem.className = 'project-option';
listItem.textContent = project.title;
listItem.addEventListener('click', () => {
const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
moveTask(taskId, project.id, taskElement);
selectorContainer.remove();
});
projectList.appendChild(listItem);
});
}
populateProjectList();
selectorContainer.appendChild(projectList);
searchInput.addEventListener('input', (e) => {
populateProjectList(e.target.value);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const query = searchInput.value.trim();
if (query.length > 1) {
const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(query.toLowerCase()));
if (filteredProjects.length > 0) {
const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
moveTask(taskId, filteredProjects[0].id, taskElement);
selectorContainer.remove();
}
}
}
});
document.body.appendChild(selectorContainer);
// Focus the input on non-touch devices
if (!('ontouchstart' in window)) {
searchInput.focus();
}
// Close the selector if clicking outside
document.addEventListener('click', function closeSelector(e) {
if (!selectorContainer.contains(e.target) && e.target !== button) {
selectorContainer.remove();
document.removeEventListener('click', closeSelector);
}
});
}
// Function to add the control buttons to a task
function addControlButtons(taskElement) {
if (taskElement.querySelector('.control-btn')) {
return; // Buttons already added
}
const favoriteButton = taskElement.querySelector('.favorite');
if (favoriteButton) {
const buttonContainer = document.createElement('span');
buttonContainer.className = 'control-buttons';
// '#' button
const tagButton = document.createElement('button');
tagButton.textContent = '#';
tagButton.className = 'base-button base-button--type-button favorite control-btn';
tagButton.addEventListener('click', (event) => {
event.stopPropagation();
showLabelSelector(event, taskElement);
});
buttonContainer.appendChild(tagButton);
// '@' button
const switchButton = document.createElement('button');
switchButton.textContent = '@';
switchButton.className = 'base-button base-button--type-button favorite control-btn quick-switch-btn';
switchButton.addEventListener('click', (event) => {
event.stopPropagation();
showProjectSelector(event, taskElement);
});
buttonContainer.appendChild(switchButton);
// '!' button (Priority)
const priorityButton = document.createElement('button');
priorityButton.textContent = '!';
priorityButton.className = 'base-button base-button--type-button favorite control-btn priority-btn';
priorityButton.addEventListener('click', (event) => {
event.stopPropagation();
showPrioritySelector(event, taskElement);
});
buttonContainer.appendChild(priorityButton);
// Delete button
const deleteButton = document.createElement('button');
deleteButton.textContent = 'X';
deleteButton.className = 'base-button base-button--type-button favorite control-btn delete-btn';
let deleteTimeout = null;
deleteButton.addEventListener('click', (event) => {
event.stopPropagation();
if (deleteButton.classList.contains('delete-confirm')) {
clearTimeout(deleteTimeout);
const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
deleteTask(taskId, taskElement);
} else {
deleteButton.classList.add('delete-confirm');
// Blur the button to remove focus, so hover-out works correctly
deleteButton.blur();
deleteTimeout = setTimeout(() => {
deleteButton.classList.remove('delete-confirm');
}, 3000);
}
});
buttonContainer.appendChild(deleteButton);
favoriteButton.parentNode.insertBefore(buttonContainer, favoriteButton);
}
}
// Use MutationObserver to detect when tasks are added to the DOM
let observer = null;
let currentTargetNode = null;
let currentUrl = window.location.href;
function createObserver() {
return new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // ELEMENT_NODE
if (node.matches('.task.single-task')) {
addControlButtons(node);
}
node.querySelectorAll('.task.single-task').forEach(addControlButtons);
}
});
}
}
});
}
// Start observing the main content area for changes
function startObserver() {
// First, disconnect any existing observer
if (observer) {
observer.disconnect();
}
const targetNode = document.querySelector('.card-content');
if (targetNode && targetNode !== currentTargetNode) {
currentTargetNode = targetNode;
observer = createObserver();
observer.observe(targetNode, {
childList: true,
subtree: true
});
// Initial run for tasks already on the page
targetNode.querySelectorAll('.task.single-task').forEach(addControlButtons);
log('Observer started for target:', targetNode);
} else if (!targetNode) {
// If the target node is not yet available, try again after a short delay
currentTargetNode = null;
setTimeout(startObserver, 500);
}
}
// Add a periodic check to ensure the observer is still working
function ensureObserverActive() {
const newUrl = window.location.href;
const targetNode = document.querySelector('.card-content');
// Check if URL changed (client-side navigation)
if (newUrl !== currentUrl) {
log('URL changed, restarting observer');
currentUrl = newUrl;
currentTargetNode = null;
startObserver();
return;
}
// Check if target node changed or disappeared
if (targetNode && targetNode !== currentTargetNode) {
log('Target node changed, restarting observer');
startObserver();
} else if (!targetNode && currentTargetNode) {
log('Target node removed, will restart when available');
currentTargetNode = null;
if (observer) {
observer.disconnect();
observer = null;
}
startObserver();
}
}
// Initial setup
log('Userscript loaded.');
fetchProjects();
fetchAllLabels();
startObserver();
// Check every 2 seconds if the observer is still active
setInterval(ensureObserverActive, 2000);
// Add some basic styling for our buttons and label selector
GM_addStyle(`
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
20%, 40%, 60%, 80% { transform: translateX(2px); }
}
.delete-btn.delete-confirm {
color: red !important;
animation: shake 0.5s !important;
}
.control-btn {
text-align: center;
width: 27px;
transition: opacity .15s ease,color .15s ease;
border-radius: 4px;
opacity: 1;
margin-right: 5px;
}
/* Responsive padding for small screens */
@media (max-width: 768px) {
.control-btn {
margin-right: -8px;
}
}
@media (hover: hover) and (pointer: fine) {
.task .control-btn {
opacity: 0;
}
.task:hover .control-btn, .task:focus-within .control-btn {
opacity: 1;
}
}
/* Label selector styling to match native Vikunja */
#label-quick-selector {
position: relative;
}
#label-quick-selector .control {
box-sizing: border-box;
clear: both;
font-size: 1rem;
position: relative;
text-align: inherit;
}
#label-quick-selector .input-wrapper {
padding: .25rem !important;
background: var(--white, #fff);
border-color: var(--primary, #1FB6F6);
border: 1px solid;
border-radius: 4px 4px 0 0;
flex-wrap: wrap;
height: auto;
display: flex;
align-items: flex-start;
min-height: 2.5em;
}
#label-quick-selector .input-wrapper .input {
flex: 1;
min-width: 100px;
margin: 0;
padding: calc(.5em - 1px) 0;
height: auto;
}
#label-quick-selector .search-results {
background: var(--white, #fff);
border: 1px solid var(--primary, #1FB6F6);
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
}
#label-quick-selector .search-result-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background-color .15s ease;
}
#label-quick-selector .search-result-button:hover {
background-color: var(--grey-200, #f1f3f9) !important;
}
#label-quick-selector .tag {
display: inline-flex;
align-items: center;
padding: .25em .75em;
border-radius: 4px;
font-size: .75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
#label-quick-selector .tag.search-result {
margin-right: .5rem;
}
#label-quick-selector .hint-text {
color: var(--text-light, #7a7a7a);
font-size: .875rem;
}
#label-quick-selector .delete {
background: none;
border: none;
color: inherit;
cursor: pointer;
opacity: .7;
transition: opacity .15s ease;
padding: 0;
margin-left: .5rem;
width: 12px;
height: 12px;
position: relative;
}
#label-quick-selector .delete:hover {
opacity: 1;
}
#label-quick-selector .delete::before,
#label-quick-selector .delete::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 1px;
background: currentColor;
transform: translate(-50%, -50%) rotate(45deg);
}
#label-quick-selector .delete::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
/* Handle dark mode if present */
:root.dark #label-quick-selector .input-wrapper {
background: var(--grey-50, #0D1117);
border-color: var(--primary, #1FB6F6);
}
:root.dark #label-quick-selector .search-results {
background: var(--grey-50, #0D1117);
border-color: var(--primary, #1FB6F6);
}
:root.dark #label-quick-selector .search-result-button:hover {
background-color: var(--grey-100, #21262D) !important;
}
/* Priority selector styling to match native Vikunja */
#priority-quick-selector {
position: relative;
}
#priority-quick-selector .priority-option:hover {
background-color: var(--grey-200, #f1f3f9) !important;
}
/* Handle dark mode for priority selector */
:root.dark #priority-quick-selector {
background-color: var(--grey-50, #0D1117) !important;
border-color: var(--primary, #1FB6F6) !important;
}
:root.dark #priority-quick-selector .priority-option {
border-top-color: var(--grey-100, #21262D) !important;
color: var(--text, #fff) !important;
}
:root.dark #priority-quick-selector .priority-option:hover {
background-color: var(--grey-100, #21262D) !important;
}
/* Project selector styling to match native Vikunja */
#project-quick-switcher {
position: relative;
background: var(--white, #fff);
border: 1px solid var(--primary, #1FB6F6);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#project-quick-switcher .project-search-input {
width: 100%;
box-sizing: border-box;
padding: .75rem;
border: none;
border-bottom: 1px solid var(--grey-200, #f1f3f9);
border-radius: 4px 4px 0 0;
background: transparent;
font-size: .875rem;
outline: none;
}
#project-quick-switcher .project-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 300px;
overflow-y: auto;
}
#project-quick-switcher .project-option {
padding: .75rem;
cursor: pointer;
transition: background-color .15s ease;
font-size: .875rem;
border-top: 1px solid var(--grey-200, #f1f3f9);
}
#project-quick-switcher .project-option:first-child {
border-top: none;
}
#project-quick-switcher .project-option:hover {
background-color: var(--grey-200, #f1f3f9) !important;
}
/* Handle dark mode for project selector */
:root.dark #project-quick-switcher {
background-color: var(--grey-50, #0D1117) !important;
border-color: var(--primary, #1FB6F6) !important;
}
:root.dark #project-quick-switcher .project-search-input {
border-bottom-color: var(--grey-100, #21262D) !important;
color: var(--text, #fff) !important;
}
:root.dark #project-quick-switcher .project-option {
border-top-color: var(--grey-100, #21262D) !important;
color: var(--text, #fff) !important;
}
:root.dark #project-quick-switcher .project-option:hover {
background-color: var(--grey-100, #21262D) !important;
}
`);
})();
@Yandrik
Copy link
Author

Yandrik commented Aug 3, 2025

Updated (v1.2): fixes for labels

Labels now also work correctly on tasks that don't already have at least 1 label (they didn't before)

Original Script (Version 1.1)
// ==UserScript==
// @name         Vikunja Quick Project Switch (try.vikunja.io)
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds a button to quickly move tasks to other projects, add tags, and delete tasks in Vikunja.
// @author       Yandrik
// @match        https://try.vikunja.io/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// ==/UserScript==

// USAGE:
// install in Tampermonkey (create new, copy -> paste)
// There, in the editor:
// ENTER YOUR VICUNJA INSTANCE URL IN @match!

(function() {
    'use strict';

    const debug = false;
    let projects = [];
    let allLabels = [];

    // Function to log messages if debug is true
    function log(...messages) {
        if (debug) {
            console.log('[Vikunja Quick Switch]', ...messages);
        }
    }

    // Function to get the JWT token from local storage
    function getJwtToken() {
        return localStorage.getItem('token');
    }

    // Function to fetch all projects
    function fetchProjects() {
	log('fetchProjects');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: '/api/v1/projects',
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 200) {
                    projects = JSON.parse(response.responseText);
                    log('Projects loaded:', projects);
                } else {
                    log('Failed to fetch projects:', response.statusText);
                }
            },
            onerror: function(error) {
                log('Error fetching projects:', error);
            }
        });
    }

    // Function to fetch all labels
    function fetchAllLabels() {
        log('fetchAllLabels');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        allLabels = JSON.parse(response.responseText);
                        log('All labels loaded:', allLabels);
                        resolve(allLabels);
                    } else {
                        log('Failed to fetch labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching labels:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to fetch labels for a specific task
    function fetchTaskLabels(taskId) {
        log('fetchTaskLabels', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        const taskLabels = JSON.parse(response.responseText);
                        log('Task labels loaded:', taskLabels);
                        resolve(taskLabels);
                    } else {
                        log('Failed to fetch task labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching task labels:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to create a new label
    function createLabel(title, hexColor = 'fd8a09') {
        log('createLabel', title, hexColor);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(null);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    title: title,
                    hex_color: hexColor
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        const newLabel = JSON.parse(response.responseText);
                        log('Label created:', newLabel);
                        allLabels.push(newLabel);
                        resolve(newLabel);
                    } else {
                        log('Failed to create label:', response.statusText);
                        resolve(null);
                    }
                },
                onerror: function(error) {
                    log('Error creating label:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to add a label to a task
    function addLabelToTask(taskId, labelId) {
        log('addLabelToTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    label_id: labelId
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        log('Label added to task successfully');
                        resolve(true);
                    } else {
                        log('Failed to add label to task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error adding label to task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to remove a label from a task
    function removeLabelFromTask(taskId, labelId) {
        log('removeLabelFromTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'DELETE',
                url: `/api/v1/tasks/${taskId}/labels/${labelId}`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200 || response.status === 204) {
                        log('Label removed from task successfully');
                        resolve(true);
                    } else {
                        log('Failed to remove label from task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error removing label from task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to perform fuzzy search on labels
    function fuzzySearch(needle, haystack) {
        if (!needle) return true;
        
        const needleLower = needle.toLowerCase();
        const haystackLower = haystack.toLowerCase();
        
        // Simple fuzzy search: check if all characters from needle exist in order in haystack
        let needleIndex = 0;
        for (let i = 0; i < haystackLower.length && needleIndex < needleLower.length; i++) {
            if (haystackLower[i] === needleLower[needleIndex]) {
                needleIndex++;
            }
        }
        return needleIndex === needleLower.length;
    }

    // Function to get text color based on background color
    function getTextColor(hexColor) {
        // Remove # if present
        const color = hexColor.replace('#', '');
        const r = parseInt(color.substr(0, 2), 16);
        const g = parseInt(color.substr(2, 2), 16);
        const b = parseInt(color.substr(4, 2), 16);
        
        // Calculate luminance
        const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        
        // Return dark or light text color based on background luminance
        return luminance > 0.5 ? 'hsl(215, 27.9%, 16.9%)' : 'hsl(220, 14.3%, 95.9%)';
    }

    // Function to create and show the label selector
    async function showLabelSelector(event, taskElement) {
        const existingSelector = document.getElementById('label-quick-selector');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();
        const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();

        // Fetch data in parallel - always refresh labels
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always fetch fresh labels
        ]);

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'label-quick-selector';
        selectorContainer.className = 'multiselect has-search-results';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.right - 350}px`; // Position to the left of the button
        selectorContainer.style.width = '350px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.tabIndex = -1;

        const controlDiv = document.createElement('div');
        controlDiv.className = 'control';

        const inputWrapper = document.createElement('div');
        inputWrapper.className = 'input-wrapper input has-multiple';

        // Add current task labels as chips
        taskLabels.forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                // Refresh the selector after removing a label, but don't close it
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.appendChild(chip);
        });

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.className = 'input';
        searchInput.placeholder = 'Type to add a label…';
        searchInput.style.border = 'none';
        searchInput.style.outline = 'none';
        searchInput.style.backgroundColor = 'transparent';
        
        inputWrapper.appendChild(searchInput);
        controlDiv.appendChild(inputWrapper);
        selectorContainer.appendChild(controlDiv);

        const searchResults = document.createElement('div');
        searchResults.className = 'search-results';
        selectorContainer.appendChild(searchResults);

        let selectedIndex = -1;

        function updateSearchResults(query = '') {
            searchResults.innerHTML = '';
            selectedIndex = -1;

            // Filter available labels (excluding already attached ones) - use fresh taskLabels
            const currentTaskLabels = Array.from(inputWrapper.querySelectorAll('.tag')).map(chip => ({
                title: chip.querySelector('span').textContent
            }));
            const attachedLabelTitles = currentTaskLabels.map(l => l.title);
            const availableLabels = allLabels.filter(label => 
                !attachedLabelTitles.includes(label.title) && 
                fuzzySearch(query, label.title)
            );

            // Show matching existing labels
            availableLabels.forEach((label, index) => {
                const resultButton = document.createElement('button');
                resultButton.type = 'button';
                resultButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                labelChip.style.background = `#${label.hex_color}`;
                labelChip.style.color = getTextColor(label.hex_color);
                
                const labelText = document.createElement('span');
                labelText.textContent = label.title;
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Click or press enter to select';
                
                resultButton.appendChild(labelSpan);
                resultButton.appendChild(hintText);
                
                resultButton.addEventListener('click', async () => {
                    await addLabelToTask(taskId, label.id);
                    refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                });
                
                searchResults.appendChild(resultButton);
            });

            // Show "Add as new label" option if query exists and no exact match among ALL labels (not just available ones)
            if (query.trim() && !allLabels.some(l => l.title.toLowerCase() === query.toLowerCase())) {
                const createButton = document.createElement('button');
                createButton.type = 'button';
                createButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                
                const labelText = document.createElement('span');
                labelText.textContent = query.trim();
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Add this as new label';
                
                createButton.appendChild(labelSpan);
                createButton.appendChild(hintText);
                
                createButton.addEventListener('click', async () => {
                    const newLabel = await createLabel(query.trim());
                    if (newLabel) {
                        await addLabelToTask(taskId, newLabel.id);
                        refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                    }
                });
                
                searchResults.appendChild(createButton);
            }
        }

        // Keyboard navigation
        searchInput.addEventListener('keydown', (e) => {
            const results = searchResults.querySelectorAll('.search-result-button');
            
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
                updateSelection(results);
            } else if (e.key === 'ArrowUp') {
                e.preventDefault();
                selectedIndex = Math.max(selectedIndex - 1, -1);
                updateSelection(results);
            } else if (e.key === 'Enter') {
                e.preventDefault();
                if (selectedIndex >= 0 && results[selectedIndex]) {
                    results[selectedIndex].click();
                } else if (searchInput.value.trim()) {
                    const query = searchInput.value.trim();
                    
                    // Check if there's an exact match with an existing label first
                    const currentTaskLabels = Array.from(inputWrapper.querySelectorAll('.tag')).map(chip => ({
                        title: chip.querySelector('span').textContent
                    }));
                    const attachedLabelTitles = currentTaskLabels.map(l => l.title);
                    const exactMatch = allLabels.find(label => 
                        !attachedLabelTitles.includes(label.title) && 
                        label.title.toLowerCase() === query.toLowerCase()
                    );
                    
                    if (exactMatch) {
                        // Use existing label
                        addLabelToTask(taskId, exactMatch.id).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    } else {
                        // Create new label only if no exact match exists
                        createLabel(query).then(newLabel => {
                            if (newLabel) {
                                return addLabelToTask(taskId, newLabel.id);
                            }
                        }).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    }
                }
            } else if (e.key === 'Escape') {
                selectorContainer.remove();
            }
        });

        function updateSelection(results) {
            results.forEach((result, index) => {
                if (index === selectedIndex) {
                    result.style.backgroundColor = 'var(--grey-200, #f1f3f9)';
                } else {
                    result.style.backgroundColor = '';
                }
            });
        }

        searchInput.addEventListener('input', (e) => {
            updateSearchResults(e.target.value);
        });

        updateSearchResults();
        document.body.appendChild(selectorContainer);

        // Focus the input
        searchInput.focus();

        // Close selector when clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to update the task's label display in the task list
    function updateTaskLabelsDisplay(taskElement, taskLabels) {
        const labelWrapper = taskElement.querySelector('.label-wrapper.labels');
        if (!labelWrapper) {
            log('No label wrapper found in task element');
            return;
        }

        // Get the Vue data attributes from an existing label if any exist
        const existingLabel = labelWrapper.querySelector('.tag');
        let outerDataAttrs = '';
        let innerDataAttrs = '';
        
        if (existingLabel) {
            // Extract data attributes from existing labels
            const outerAttrs = Array.from(existingLabel.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ');
            
            const innerSpan = existingLabel.querySelector('span');
            const innerAttrs = innerSpan ? Array.from(innerSpan.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ') : '';
                
            outerDataAttrs = outerAttrs;
            innerDataAttrs = innerAttrs;
        }

        // Clear existing labels
        labelWrapper.innerHTML = '';

        // Add updated labels with proper Vue data attributes
        taskLabels.forEach(label => {
            const labelSpan = document.createElement('span');
            labelSpan.className = 'tag';
            labelSpan.style.background = `#${label.hex_color}`;
            labelSpan.style.color = getTextColor(label.hex_color);
            
            // Add Vue data attributes for proper styling
            if (outerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span class="tag" ${outerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelSpan.setAttribute(attr.name, attr.value);
                    }
                });
            }

            const labelText = document.createElement('span');
            labelText.textContent = label.title;
            
            // Add Vue data attributes to inner span
            if (innerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span ${innerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelText.setAttribute(attr.name, attr.value);
                    }
                });
            }
            
            labelSpan.appendChild(labelText);
            labelWrapper.appendChild(labelSpan);
        });
    }

    // Function to refresh the label selector without closing it
    async function refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement = null) {
        // Fetch fresh data
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always refresh all labels too
        ]);
        
        const inputWrapper = selectorContainer.querySelector('.input-wrapper');
        
        // Clear existing chips (but keep the input)
        const existingChips = inputWrapper.querySelectorAll('.tag');
        existingChips.forEach(chip => chip.remove());
        
        // Re-add current task labels as chips
        taskLabels.forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.insertBefore(chip, searchInput);
        });

        // Update the task's label display in the task list
        if (taskElement) {
            updateTaskLabelsDisplay(taskElement, taskLabels);
        }
        
        // Clear search input and trigger update to refresh the search results
        searchInput.value = '';
        searchInput.dispatchEvent(new Event('input'));
        searchInput.focus();
    }

    // Function to create a label chip
    function createLabelChip(label, taskId, onRemove) {
        const chip = document.createElement('span');
        chip.className = 'tag';
        chip.style.background = `#${label.hex_color}`;
        chip.style.color = getTextColor(label.hex_color);
        chip.style.margin = '0 0 0 .5rem';

        const labelText = document.createElement('span');
        labelText.textContent = label.title;
        chip.appendChild(labelText);

        const deleteButton = document.createElement('button');
        deleteButton.type = 'button';
        deleteButton.className = 'base-button base-button--type-button delete is-small';
        deleteButton.addEventListener('click', async (e) => {
            e.stopPropagation();
            await removeLabelFromTask(taskId, label.id);
            if (onRemove) onRemove();
        });
        chip.appendChild(deleteButton);

        return chip;
    }

    // Function to move a task to a different project
    function moveTask(taskId, projectId, taskElement) {
        log('moveTask', taskId, projectId, taskElement);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        // First, fetch the current task details
        GM_xmlhttpRequest({
            method: 'GET',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status !== 200) {
                    log('Failed to fetch task details:', response.statusText);
                    alert('Failed to fetch task details before moving.');
                    return;
                }

                const task = JSON.parse(response.responseText);
                task.project_id = projectId;

		log('task to be updated:', task, 'projID:', projectId);

                // Now, update the task with the new project_id
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: `/api/v1/tasks/${taskId}`,
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json'
                    },
                    data: JSON.stringify(task),
                    onload: function(response) {
                        if (response.status === 200) {
                            log(`Task ${taskId} moved to project ${projectId}`);
                            // Remove the task from the list only if we're in a specific project view with a real project ID
                            const match = window.location.pathname.match(/\/projects\/(\d+)/);
                            if (match && parseInt(match[1]) >= 0) {
                                taskElement.remove();
                            } else {
                                // Update the project link and text if task remains visible
                                const projectLink = taskElement.querySelector('.task-project');
                                if (projectLink) {
                                    const targetProject = projects.find(p => p.id === projectId);
                                    if (targetProject) {
                                        projectLink.href = `/projects/${projectId}`;
                                        projectLink.textContent = targetProject.title;
                                    }
                                }
                            }
                        } else {
                            log(`Failed to move task:`);
                            log(response);
                            alert('Failed to move the task. Please try again.');
                        }
                    },
                    onerror: function(error) {
                        log('Error moving task:', error);
                        alert('An error occurred while moving the task.');
                    }
                });
            },
            onerror: function(error) {
                log('Error fetching task details:', error);
                alert('An error occurred while fetching task details.');
            }
        });
    }

    // Function to delete a task
    function deleteTask(taskId, taskElement) {
        log('deleteTask', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'DELETE',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 204 || response.status === 200) { // Successfully deleted
                    log(`Task ${taskId} deleted`);
                    taskElement.remove();
                } else {
                    log(`Failed to delete task:`, response);
                    alert('Failed to delete the task. Please try again.');
                }
            },
            onerror: function(error) {
                log('Error deleting task:', error);
                alert('An error occurred while deleting the task.');
            }
        });
    }

    // Function to create and show the project selection combobox
    function showProjectSelector(event, taskElement) {
        const existingSelector = document.getElementById('project-quick-switcher');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'project-quick-switcher';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.left}px`;
        selectorContainer.style.backgroundColor = '#333';
        selectorContainer.style.border = '1px solid #555';
        selectorContainer.style.borderRadius = '4px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.style.padding = '5px';

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = 'Search projects...';
        searchInput.style.width = '100%';
        searchInput.style.boxSizing = 'border-box';
        searchInput.style.marginBottom = '5px';
        selectorContainer.appendChild(searchInput);

        const projectList = document.createElement('ul');
        projectList.style.listStyle = 'none';
        projectList.style.margin = '0';
        projectList.style.padding = '0';
        projectList.style.maxHeight = '200px';
        projectList.style.overflowY = 'auto';

        function populateProjectList(filter = '') {
            projectList.innerHTML = '';
            const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(filter.toLowerCase()));

            filteredProjects.forEach(project => {
                const listItem = document.createElement('li');
                listItem.textContent = project.title;
                listItem.style.padding = '5px';
                listItem.style.cursor = 'pointer';

                listItem.addEventListener('mouseover', () => {
                    listItem.style.backgroundColor = '#555';
                });
                listItem.addEventListener('mouseout', () => {
                    listItem.style.backgroundColor = 'transparent';
                });

                listItem.addEventListener('click', () => {
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    moveTask(taskId, project.id, taskElement);
                    selectorContainer.remove();
                });
                projectList.appendChild(listItem);
            });
        }

        populateProjectList();
        selectorContainer.appendChild(projectList);

        searchInput.addEventListener('input', (e) => {
            populateProjectList(e.target.value);
        });

        document.body.appendChild(selectorContainer);

        // Close the selector if clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to add the control buttons to a task
    function addControlButtons(taskElement) {
        if (taskElement.querySelector('.control-btn')) {
            return; // Buttons already added
        }

        const favoriteButton = taskElement.querySelector('.favorite');
        if (favoriteButton) {
            const buttonContainer = document.createElement('span');
            buttonContainer.className = 'control-buttons';

            // '#' button
            const tagButton = document.createElement('button');
            tagButton.textContent = '#';
            tagButton.className = 'base-button base-button--type-button favorite control-btn';
            tagButton.style.marginRight = '5px';
            tagButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showLabelSelector(event, taskElement);
            });
            buttonContainer.appendChild(tagButton);

            // '@' button
            const switchButton = document.createElement('button');
            switchButton.textContent = '@';
            switchButton.className = 'base-button base-button--type-button favorite control-btn quick-switch-btn';
            switchButton.style.marginRight = '5px';
            switchButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showProjectSelector(event, taskElement);
            });
            buttonContainer.appendChild(switchButton);

            // Delete button
            const deleteButton = document.createElement('button');
            deleteButton.textContent = 'X';
            deleteButton.className = 'base-button base-button--type-button favorite control-btn delete-btn';
            deleteButton.style.marginRight = '5px';
            let deleteTimeout = null;
            deleteButton.addEventListener('click', (event) => {
                event.stopPropagation();
                if (deleteButton.classList.contains('delete-confirm')) {
                    clearTimeout(deleteTimeout);
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    deleteTask(taskId, taskElement);
                } else {
                    deleteButton.classList.add('delete-confirm');
                    // Blur the button to remove focus, so hover-out works correctly
                    deleteButton.blur();
                    deleteTimeout = setTimeout(() => {
                        deleteButton.classList.remove('delete-confirm');
                    }, 3000);
                }
            });
            buttonContainer.appendChild(deleteButton);

            favoriteButton.parentNode.insertBefore(buttonContainer, favoriteButton);
        }
    }

    // Use MutationObserver to detect when tasks are added to the DOM
    let observer = null;
    let currentTargetNode = null;
    let currentUrl = window.location.href;

    function createObserver() {
        return new MutationObserver((mutationsList, observer) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) { // ELEMENT_NODE
                            if (node.matches('.task.single-task')) {
                                addControlButtons(node);
                            }
                            node.querySelectorAll('.task.single-task').forEach(addControlButtons);
                        }
                    });
                }
            }
        });
    }

    // Start observing the main content area for changes
    function startObserver() {
        // First, disconnect any existing observer
        if (observer) {
            observer.disconnect();
        }
        
        const targetNode = document.querySelector('.card-content');
        if (targetNode && targetNode !== currentTargetNode) {
            currentTargetNode = targetNode;
            observer = createObserver();
            
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            
            // Initial run for tasks already on the page
            targetNode.querySelectorAll('.task.single-task').forEach(addControlButtons);
            log('Observer started for target:', targetNode);
        } else if (!targetNode) {
            // If the target node is not yet available, try again after a short delay
            currentTargetNode = null;
            setTimeout(startObserver, 500);
        }
    }

    // Add a periodic check to ensure the observer is still working
    function ensureObserverActive() {
        const newUrl = window.location.href;
        const targetNode = document.querySelector('.card-content');
        
        // Check if URL changed (client-side navigation)
        if (newUrl !== currentUrl) {
            log('URL changed, restarting observer');
            currentUrl = newUrl;
            currentTargetNode = null;
            startObserver();
            return;
        }
        
        // Check if target node changed or disappeared
        if (targetNode && targetNode !== currentTargetNode) {
            log('Target node changed, restarting observer');
            startObserver();
        } else if (!targetNode && currentTargetNode) {
            log('Target node removed, will restart when available');
            currentTargetNode = null;
            if (observer) {
                observer.disconnect();
                observer = null;
            }
            startObserver();
        }
    }

    // Initial setup
    log('Userscript loaded.');
    fetchProjects();
    fetchAllLabels();
    startObserver();
    
    // Check every 2 seconds if the observer is still active
    setInterval(ensureObserverActive, 2000);

    // Add some basic styling for our buttons and label selector
    GM_addStyle(`
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
            20%, 40%, 60%, 80% { transform: translateX(2px); }
        }
        .delete-btn.delete-confirm {
            color: red !important;
            animation: shake 0.5s !important;
        }
        .control-btn {
            text-align: center;
            width: 27px;
            transition: opacity .15s ease,color .15s ease;
            border-radius: 4px;
            opacity: 1;
        }
        @media (hover: hover) and (pointer: fine) {
            .task .control-btn {
                opacity: 0;
            }
            .task:hover .control-btn, .task:focus-within .control-btn {
                opacity: 1;
            }
        }
        /* Label selector styling to match native Vikunja */
        #label-quick-selector {
            position: relative;
        }
        
        #label-quick-selector .control {
            box-sizing: border-box;
            clear: both;
            font-size: 1rem;
            position: relative;
            text-align: inherit;
        }
        
        #label-quick-selector .input-wrapper {
            padding: .25rem !important;
            background: var(--white, #fff);
            border-color: var(--primary, #1FB6F6);
            border: 1px solid;
            border-radius: 4px 4px 0 0;
            flex-wrap: wrap;
            height: auto;
            display: flex;
            align-items: flex-start;
            min-height: 2.5em;
        }
        
        #label-quick-selector .input-wrapper .input {
            flex: 1;
            min-width: 100px;
            margin: 0;
            padding: calc(.5em - 1px) 0;
            height: auto;
        }
        
        #label-quick-selector .search-results {
            background: var(--white, #fff);
            border: 1px solid var(--primary, #1FB6F6);
            border-top: none;
            border-radius: 0 0 4px 4px;
            max-height: 200px;
            overflow-y: auto;
        }
        
        #label-quick-selector .search-result-button {
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: .5rem;
            border: none;
            background: transparent;
            text-align: left;
            cursor: pointer;
            transition: background-color .15s ease;
        }
        
        #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-200, #f1f3f9) !important;
        }
        
        #label-quick-selector .tag {
            display: inline-flex;
            align-items: center;
            padding: .25em .75em;
            border-radius: 4px;
            font-size: .75rem;
            font-weight: 600;
            line-height: 1;
            white-space: nowrap;
        }
        
        #label-quick-selector .tag.search-result {
            margin-right: .5rem;
        }
        
        #label-quick-selector .hint-text {
            color: var(--text-light, #7a7a7a);
            font-size: .875rem;
        }
        
        #label-quick-selector .delete {
            background: none;
            border: none;
            color: inherit;
            cursor: pointer;
            opacity: .7;
            transition: opacity .15s ease;
            padding: 0;
            margin-left: .5rem;
            width: 12px;
            height: 12px;
            position: relative;
        }
        
        #label-quick-selector .delete:hover {
            opacity: 1;
        }
        
        #label-quick-selector .delete::before,
        #label-quick-selector .delete::after {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 8px;
            height: 1px;
            background: currentColor;
            transform: translate(-50%, -50%) rotate(45deg);
        }
        
        #label-quick-selector .delete::after {
            transform: translate(-50%, -50%) rotate(-45deg);
        }
        
        /* Handle dark mode if present */
        :root.dark #label-quick-selector .input-wrapper {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-results {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-100, #21262D) !important;
        }
    `);
})();

@Yandrik
Copy link
Author

Yandrik commented Aug 4, 2025

Updated (v1.3): Priority Switching

Supported right now:

  • Priority switching (toggle via click or keyboard shortcut)
  • Improved visual styling (cleaner UI, better spacing, responsive design)
  • Project quick switch styling improvements (better dropdown and better keyboard-based, similar to the original)

Not yet supported:

  • Drag-and-drop reordering (planned for v1.4)
  • Custom theme support (coming in v1.5)
  • Dark mode toggle (in development)
Original Script (Version 1.2)
// ==UserScript==
// @name         Vikunja Quick Project Switch (try.vikunja.io)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Adds a button to quickly move tasks to other projects, add tags, and delete tasks in Vikunja.
// @author       Yandrik
// @match        https://try.vikunja.io/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// ==/UserScript==

// USAGE:
// install in Tampermonkey (create new, copy -> paste)
// There, in the editor:
// ENTER YOUR VICUNJA INSTANCE URL IN @match!

(function() {
    'use strict';

    const debug = false;
    let projects = [];
    let allLabels = [];

    // Function to log messages if debug is true
    function log(...messages) {
        if (debug) {
            console.log('[Vikunja Quick Switch]', ...messages);
        }
    }

    // Function to get the JWT token from local storage
    function getJwtToken() {
        return localStorage.getItem('token');
    }

    // Function to fetch all projects
    function fetchProjects() {
	log('fetchProjects');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: '/api/v1/projects',
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 200) {
                    projects = JSON.parse(response.responseText);
                    log('Projects loaded:', projects);
                } else {
                    log('Failed to fetch projects:', response.statusText);
                }
            },
            onerror: function(error) {
                log('Error fetching projects:', error);
            }
        });
    }

    // Function to fetch all labels
    function fetchAllLabels() {
        log('fetchAllLabels');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        allLabels = JSON.parse(response.responseText);
                        log('All labels loaded:', allLabels);
                        resolve(allLabels);
                    } else {
                        log('Failed to fetch labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching labels:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to fetch labels for a specific task
    function fetchTaskLabels(taskId) {
        log('fetchTaskLabels', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        const taskLabels = JSON.parse(response.responseText);
                        log('Task labels loaded:', taskLabels);
                        resolve(taskLabels);
                    } else {
                        log('Failed to fetch task labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching task labels:', error);
                    reject(error);
                }
            });
        });
    }

    const COLORS = [
        '#ffbe0b',
        '#fd8a09',
        '#fb5607',
        '#ff006e',
        '#efbdeb',
        '#8338ec',
        '#5f5ff6',
        '#3a86ff',
        '#4c91ff',
        '#0ead69',
        '#25be8b',
        '#073b4c',
        '#373f47',
    ];

    function getRandomColorHex() {
        return COLORS[Math.floor(Math.random() * COLORS.length)];
    }

    // Function to create a new label
    function createLabel(title, hexColor = null) {
        if (!hexColor) {
            hexColor = getRandomColorHex().replace('#', '');
        }
        log('createLabel', title, hexColor);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(null);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    title: title,
                    hex_color: hexColor
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        const newLabel = JSON.parse(response.responseText);
                        log('Label created:', newLabel);
                        allLabels.push(newLabel);
                        resolve(newLabel);
                    } else {
                        log('Failed to create label:', response.statusText);
                        resolve(null);
                    }
                },
                onerror: function(error) {
                    log('Error creating label:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to add a label to a task
    function addLabelToTask(taskId, labelId) {
        log('addLabelToTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    label_id: labelId
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        log('Label added to task successfully');
                        resolve(true);
                    } else {
                        log('Failed to add label to task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error adding label to task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to remove a label from a task
    function removeLabelFromTask(taskId, labelId) {
        log('removeLabelFromTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'DELETE',
                url: `/api/v1/tasks/${taskId}/labels/${labelId}`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200 || response.status === 204) {
                        log('Label removed from task successfully');
                        resolve(true);
                    } else {
                        log('Failed to remove label from task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error removing label from task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to perform fuzzy search on labels
    function fuzzySearch(needle, haystack) {
        if (!needle) return true;
        
        const needleLower = needle.toLowerCase();
        const haystackLower = haystack.toLowerCase();
        
        // Simple fuzzy search: check if all characters from needle exist in order in haystack
        let needleIndex = 0;
        for (let i = 0; i < haystackLower.length && needleIndex < needleLower.length; i++) {
            if (haystackLower[i] === needleLower[needleIndex]) {
                needleIndex++;
            }
        }
        return needleIndex === needleLower.length;
    }

    // Function to get text color based on background color
    function getTextColor(hexColor) {
        // Remove # if present
        const color = hexColor.replace('#', '');
        const r = parseInt(color.substr(0, 2), 16);
        const g = parseInt(color.substr(2, 2), 16);
        const b = parseInt(color.substr(4, 2), 16);
        
        // Calculate luminance
        const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        
        // Return dark or light text color based on background luminance
        return luminance > 0.5 ? 'hsl(215, 27.9%, 16.9%)' : 'hsl(220, 14.3%, 95.9%)';
    }

    // Function to create and show the label selector
    async function showLabelSelector(event, taskElement) {
        const existingSelector = document.getElementById('label-quick-selector');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();
        const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();

        // Fetch data in parallel - always refresh labels
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always fetch fresh labels
        ]);

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'label-quick-selector';
        selectorContainer.className = 'multiselect has-search-results';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.right - 350}px`; // Position to the left of the button
        selectorContainer.style.width = '350px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.tabIndex = -1;

        const controlDiv = document.createElement('div');
        controlDiv.className = 'control';

        const inputWrapper = document.createElement('div');
        inputWrapper.className = 'input-wrapper input has-multiple';

        // Add current task labels as chips
        (taskLabels || []).forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                // Refresh the selector after removing a label, but don't close it
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.appendChild(chip);
        });

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.className = 'input';
        searchInput.placeholder = 'Type to add a label…';
        searchInput.style.border = 'none';
        searchInput.style.outline = 'none';
        searchInput.style.backgroundColor = 'transparent';
        
        inputWrapper.appendChild(searchInput);
        controlDiv.appendChild(inputWrapper);
        selectorContainer.appendChild(controlDiv);

        const searchResults = document.createElement('div');
        searchResults.className = 'search-results';
        selectorContainer.appendChild(searchResults);

        let selectedIndex = -1;

        function updateSearchResults(query = '', currentTaskLabels = taskLabels) {
            searchResults.innerHTML = '';
            selectedIndex = -1;

            // Filter available labels (excluding already attached ones) - use actual task labels data
            const attachedLabelTitles = (currentTaskLabels || []).map(l => l.title);
            const availableLabels = allLabels.filter(label => 
                !attachedLabelTitles.includes(label.title) && 
                fuzzySearch(query, label.title)
            );

            // Show matching existing labels
            availableLabels.forEach((label, index) => {
                const resultButton = document.createElement('button');
                resultButton.type = 'button';
                resultButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                labelChip.style.background = `#${label.hex_color}`;
                labelChip.style.color = getTextColor(label.hex_color);
                
                const labelText = document.createElement('span');
                labelText.textContent = label.title;
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Click or press enter to select';
                
                resultButton.appendChild(labelSpan);
                resultButton.appendChild(hintText);
                
                resultButton.addEventListener('click', async () => {
                    await addLabelToTask(taskId, label.id);
                    refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                });
                
                searchResults.appendChild(resultButton);
            });

            // Show "Add as new label" option if query exists and no exact match among ALL labels (not just available ones)
            if (query.trim() && !allLabels.some(l => l.title.toLowerCase() === query.toLowerCase())) {
                const createButton = document.createElement('button');
                createButton.type = 'button';
                createButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                
                const labelText = document.createElement('span');
                labelText.textContent = query.trim();
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Add this as new label';
                
                createButton.appendChild(labelSpan);
                createButton.appendChild(hintText);
                
                createButton.addEventListener('click', async () => {
                    const newLabel = await createLabel(query.trim());
                    if (newLabel) {
                        await addLabelToTask(taskId, newLabel.id);
                        refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                    }
                });
                
                searchResults.appendChild(createButton);
            }
        }

        // Keyboard navigation
        searchInput.addEventListener('keydown', (e) => {
            const results = searchResults.querySelectorAll('.search-result-button');
            
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
                updateSelection(results);
            } else if (e.key === 'ArrowUp') {
                e.preventDefault();
                selectedIndex = Math.max(selectedIndex - 1, -1);
                updateSelection(results);
            } else if (e.key === 'Enter') {
                e.preventDefault();
                if (selectedIndex >= 0 && results[selectedIndex]) {
                    results[selectedIndex].click();
                } else if (searchInput.value.trim()) {
                    const query = searchInput.value.trim();
                    
                    // Check if there's an exact match with an existing label first
                    const attachedLabelTitles = (taskLabels || []).map(l => l.title);
                    const exactMatch = allLabels.find(label => 
                        !attachedLabelTitles.includes(label.title) && 
                        label.title.toLowerCase() === query.toLowerCase()
                    );
                    
                    if (exactMatch) {
                        // Use existing label
                        addLabelToTask(taskId, exactMatch.id).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    } else {
                        // Create new label only if no exact match exists
                        createLabel(query).then(newLabel => {
                            if (newLabel) {
                                return addLabelToTask(taskId, newLabel.id);
                            }
                        }).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    }
                }
            } else if (e.key === 'Escape') {
                selectorContainer.remove();
            }
        });

        function updateSelection(results) {
            results.forEach((result, index) => {
                if (index === selectedIndex) {
                    result.style.backgroundColor = 'var(--grey-200, #f1f3f9)';
                } else {
                    result.style.backgroundColor = '';
                }
            });
        }

        searchInput.addEventListener('input', (e) => {
            const currentTaskLabels = e.taskLabels || taskLabels;
            updateSearchResults(e.target.value, currentTaskLabels);
        });

        updateSearchResults();
        document.body.appendChild(selectorContainer);

        // Focus the input
        searchInput.focus();

        // Close selector when clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to update the task's label display in the task list
    function updateTaskLabelsDisplay(taskElement, taskLabels) {
        let labelWrapper = taskElement.querySelector('.label-wrapper.labels');
        
        // If no label wrapper exists and we have labels to display, create it
        if (!labelWrapper && taskLabels && taskLabels.length > 0) {
            labelWrapper = document.createElement('div');
            labelWrapper.className = 'label-wrapper labels ml-2 mr-1';
            
            // Add Vue data attributes by copying from existing elements if available
            const existingLabelWrapper = document.querySelector('.label-wrapper.labels');
            if (existingLabelWrapper) {
                Array.from(existingLabelWrapper.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelWrapper.setAttribute(attr.name, attr.value);
                    }
                });
            }
            
            // Insert the label wrapper after the task link span
            const taskTextDiv = taskElement.querySelector('.tasktext');
            const taskLinkSpan = taskTextDiv.querySelector('span');
            if (taskLinkSpan && taskTextDiv) {
                taskTextDiv.insertBefore(labelWrapper, taskLinkSpan.nextSibling);
            } else {
                log('Could not find proper insertion point for label wrapper');
                return;
            }
        }
        
        // If still no label wrapper and no labels to display, nothing to do
        if (!labelWrapper) {
            return;
        }

        // Get the Vue data attributes from an existing label if any exist
        const existingLabel = labelWrapper.querySelector('.tag');
        let outerDataAttrs = '';
        let innerDataAttrs = '';
        
        if (existingLabel) {
            // Extract data attributes from existing labels
            const outerAttrs = Array.from(existingLabel.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ');
            
            const innerSpan = existingLabel.querySelector('span');
            const innerAttrs = innerSpan ? Array.from(innerSpan.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ') : '';
                
            outerDataAttrs = outerAttrs;
            innerDataAttrs = innerAttrs;
        }

        // Clear existing labels
        labelWrapper.innerHTML = '';

        // Add updated labels with proper Vue data attributes
        (taskLabels || []).forEach(label => {
            const labelSpan = document.createElement('span');
            labelSpan.className = 'tag';
            labelSpan.style.background = `#${label.hex_color}`;
            labelSpan.style.color = getTextColor(label.hex_color);
            labelSpan.style.marginRight = '0.25rem'; // Add spacing between labels
            
            // Add Vue data attributes for proper styling
            if (outerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span class="tag" ${outerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelSpan.setAttribute(attr.name, attr.value);
                    }
                });
            }

            const labelText = document.createElement('span');
            labelText.textContent = label.title;
            
            // Add Vue data attributes to inner span
            if (innerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span ${innerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelText.setAttribute(attr.name, attr.value);
                    }
                });
            }
            
            labelSpan.appendChild(labelText);
            labelWrapper.appendChild(labelSpan);
        });
    }

    // Function to refresh the label selector without closing it
    async function refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement = null) {
        // Fetch fresh data
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always refresh all labels too
        ]);
        
        const inputWrapper = selectorContainer.querySelector('.input-wrapper');
        
        // Clear existing chips (but keep the input)
        const existingChips = inputWrapper.querySelectorAll('.tag');
        existingChips.forEach(chip => chip.remove());
        
        // Re-add current task labels as chips
        (taskLabels || []).forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.insertBefore(chip, searchInput);
        });

        // Update the task's label display in the task list
        if (taskElement) {
            updateTaskLabelsDisplay(taskElement, taskLabels);
        }
        
        // Clear search input and trigger update to refresh the search results with fresh task labels
        searchInput.value = '';
        // Manually call updateSearchResults with the fresh task labels
        const updateEvent = new Event('input');
        updateEvent.taskLabels = taskLabels;
        searchInput.dispatchEvent(updateEvent);
        searchInput.focus();
    }

    // Function to create a label chip
    function createLabelChip(label, taskId, onRemove) {
        const chip = document.createElement('span');
        chip.className = 'tag';
        chip.style.background = `#${label.hex_color}`;
        chip.style.color = getTextColor(label.hex_color);
        chip.style.margin = '0 0 .25rem .5rem';

        const labelText = document.createElement('span');
        labelText.textContent = label.title;
        chip.appendChild(labelText);

        const deleteButton = document.createElement('button');
        deleteButton.type = 'button';
        deleteButton.className = 'base-button base-button--type-button delete is-small';
        deleteButton.addEventListener('click', async (e) => {
            e.stopPropagation();
            await removeLabelFromTask(taskId, label.id);
            if (onRemove) onRemove();
        });
        chip.appendChild(deleteButton);

        return chip;
    }

    // Function to move a task to a different project
    function moveTask(taskId, projectId, taskElement) {
        log('moveTask', taskId, projectId, taskElement);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        // First, fetch the current task details
        GM_xmlhttpRequest({
            method: 'GET',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status !== 200) {
                    log('Failed to fetch task details:', response.statusText);
                    alert('Failed to fetch task details before moving.');
                    return;
                }

                const task = JSON.parse(response.responseText);
                task.project_id = projectId;

		log('task to be updated:', task, 'projID:', projectId);

                // Now, update the task with the new project_id
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: `/api/v1/tasks/${taskId}`,
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json'
                    },
                    data: JSON.stringify(task),
                    onload: function(response) {
                        if (response.status === 200) {
                            log(`Task ${taskId} moved to project ${projectId}`);
                            // Remove the task from the list only if we're in a specific project view with a real project ID
                            const match = window.location.pathname.match(/\/projects\/(\d+)/);
                            if (match && parseInt(match[1]) >= 0) {
                                taskElement.remove();
                            } else {
                                // Update the project link and text if task remains visible
                                const projectLink = taskElement.querySelector('.task-project');
                                if (projectLink) {
                                    const targetProject = projects.find(p => p.id === projectId);
                                    if (targetProject) {
                                        projectLink.href = `/projects/${projectId}`;
                                        projectLink.textContent = targetProject.title;
                                    }
                                }
                            }
                        } else {
                            log(`Failed to move task:`);
                            log(response);
                            alert('Failed to move the task. Please try again.');
                        }
                    },
                    onerror: function(error) {
                        log('Error moving task:', error);
                        alert('An error occurred while moving the task.');
                    }
                });
            },
            onerror: function(error) {
                log('Error fetching task details:', error);
                alert('An error occurred while fetching task details.');
            }
        });
    }

    // Function to delete a task
    function deleteTask(taskId, taskElement) {
        log('deleteTask', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'DELETE',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 204 || response.status === 200) { // Successfully deleted
                    log(`Task ${taskId} deleted`);
                    taskElement.remove();
                } else {
                    log(`Failed to delete task:`, response);
                    alert('Failed to delete the task. Please try again.');
                }
            },
            onerror: function(error) {
                log('Error deleting task:', error);
                alert('An error occurred while deleting the task.');
            }
        });
    }

    // Function to create and show the project selection combobox
    function showProjectSelector(event, taskElement) {
        const existingSelector = document.getElementById('project-quick-switcher');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'project-quick-switcher';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.left}px`;
        selectorContainer.style.backgroundColor = '#333';
        selectorContainer.style.border = '1px solid #555';
        selectorContainer.style.borderRadius = '4px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.style.padding = '5px';

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = 'Search projects...';
        searchInput.style.width = '100%';
        searchInput.style.boxSizing = 'border-box';
        searchInput.style.marginBottom = '5px';
        selectorContainer.appendChild(searchInput);

        const projectList = document.createElement('ul');
        projectList.style.listStyle = 'none';
        projectList.style.margin = '0';
        projectList.style.padding = '0';
        projectList.style.maxHeight = '200px';
        projectList.style.overflowY = 'auto';

        function populateProjectList(filter = '') {
            projectList.innerHTML = '';
            const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(filter.toLowerCase()));

            filteredProjects.forEach(project => {
                const listItem = document.createElement('li');
                listItem.textContent = project.title;
                listItem.style.padding = '5px';
                listItem.style.cursor = 'pointer';

                listItem.addEventListener('mouseover', () => {
                    listItem.style.backgroundColor = '#555';
                });
                listItem.addEventListener('mouseout', () => {
                    listItem.style.backgroundColor = 'transparent';
                });

                listItem.addEventListener('click', () => {
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    moveTask(taskId, project.id, taskElement);
                    selectorContainer.remove();
                });
                projectList.appendChild(listItem);
            });
        }

        populateProjectList();
        selectorContainer.appendChild(projectList);

        searchInput.addEventListener('input', (e) => {
            populateProjectList(e.target.value);
        });

        document.body.appendChild(selectorContainer);

        // Close the selector if clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to add the control buttons to a task
    function addControlButtons(taskElement) {
        if (taskElement.querySelector('.control-btn')) {
            return; // Buttons already added
        }

        const favoriteButton = taskElement.querySelector('.favorite');
        if (favoriteButton) {
            const buttonContainer = document.createElement('span');
            buttonContainer.className = 'control-buttons';

            // '#' button
            const tagButton = document.createElement('button');
            tagButton.textContent = '#';
            tagButton.className = 'base-button base-button--type-button favorite control-btn';
            tagButton.style.marginRight = '5px';
            tagButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showLabelSelector(event, taskElement);
            });
            buttonContainer.appendChild(tagButton);

            // '@' button
            const switchButton = document.createElement('button');
            switchButton.textContent = '@';
            switchButton.className = 'base-button base-button--type-button favorite control-btn quick-switch-btn';
            switchButton.style.marginRight = '5px';
            switchButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showProjectSelector(event, taskElement);
            });
            buttonContainer.appendChild(switchButton);

            // Delete button
            const deleteButton = document.createElement('button');
            deleteButton.textContent = 'X';
            deleteButton.className = 'base-button base-button--type-button favorite control-btn delete-btn';
            deleteButton.style.marginRight = '5px';
            let deleteTimeout = null;
            deleteButton.addEventListener('click', (event) => {
                event.stopPropagation();
                if (deleteButton.classList.contains('delete-confirm')) {
                    clearTimeout(deleteTimeout);
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    deleteTask(taskId, taskElement);
                } else {
                    deleteButton.classList.add('delete-confirm');
                    // Blur the button to remove focus, so hover-out works correctly
                    deleteButton.blur();
                    deleteTimeout = setTimeout(() => {
                        deleteButton.classList.remove('delete-confirm');
                    }, 3000);
                }
            });
            buttonContainer.appendChild(deleteButton);

            favoriteButton.parentNode.insertBefore(buttonContainer, favoriteButton);
        }
    }

    // Use MutationObserver to detect when tasks are added to the DOM
    let observer = null;
    let currentTargetNode = null;
    let currentUrl = window.location.href;

    function createObserver() {
        return new MutationObserver((mutationsList, observer) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) { // ELEMENT_NODE
                            if (node.matches('.task.single-task')) {
                                addControlButtons(node);
                            }
                            node.querySelectorAll('.task.single-task').forEach(addControlButtons);
                        }
                    });
                }
            }
        });
    }

    // Start observing the main content area for changes
    function startObserver() {
        // First, disconnect any existing observer
        if (observer) {
            observer.disconnect();
        }
        
        const targetNode = document.querySelector('.card-content');
        if (targetNode && targetNode !== currentTargetNode) {
            currentTargetNode = targetNode;
            observer = createObserver();
            
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            
            // Initial run for tasks already on the page
            targetNode.querySelectorAll('.task.single-task').forEach(addControlButtons);
            log('Observer started for target:', targetNode);
        } else if (!targetNode) {
            // If the target node is not yet available, try again after a short delay
            currentTargetNode = null;
            setTimeout(startObserver, 500);
        }
    }

    // Add a periodic check to ensure the observer is still working
    function ensureObserverActive() {
        const newUrl = window.location.href;
        const targetNode = document.querySelector('.card-content');
        
        // Check if URL changed (client-side navigation)
        if (newUrl !== currentUrl) {
            log('URL changed, restarting observer');
            currentUrl = newUrl;
            currentTargetNode = null;
            startObserver();
            return;
        }
        
        // Check if target node changed or disappeared
        if (targetNode && targetNode !== currentTargetNode) {
            log('Target node changed, restarting observer');
            startObserver();
        } else if (!targetNode && currentTargetNode) {
            log('Target node removed, will restart when available');
            currentTargetNode = null;
            if (observer) {
                observer.disconnect();
                observer = null;
            }
            startObserver();
        }
    }

    // Initial setup
    log('Userscript loaded.');
    fetchProjects();
    fetchAllLabels();
    startObserver();
    
    // Check every 2 seconds if the observer is still active
    setInterval(ensureObserverActive, 2000);

    // Add some basic styling for our buttons and label selector
    GM_addStyle(`
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
            20%, 40%, 60%, 80% { transform: translateX(2px); }
        }
        .delete-btn.delete-confirm {
            color: red !important;
            animation: shake 0.5s !important;
        }
        .control-btn {
            text-align: center;
            width: 27px;
            transition: opacity .15s ease,color .15s ease;
            border-radius: 4px;
            opacity: 1;
        }
        @media (hover: hover) and (pointer: fine) {
            .task .control-btn {
                opacity: 0;
            }
            .task:hover .control-btn, .task:focus-within .control-btn {
                opacity: 1;
            }
        }

        /* Label selector styling to match native Vikunja */
        #label-quick-selector {
            position: relative;
        }
        
        #label-quick-selector .control {
            box-sizing: border-box;
            clear: both;
            font-size: 1rem;
            position: relative;
            text-align: inherit;
        }
        
        #label-quick-selector .input-wrapper {
            padding: .25rem !important;
            background: var(--white, #fff);
            border-color: var(--primary, #1FB6F6);
            border: 1px solid;
            border-radius: 4px 4px 0 0;
            flex-wrap: wrap;
            height: auto;
            display: flex;
            align-items: flex-start;
            min-height: 2.5em;
        }
        
        #label-quick-selector .input-wrapper .input {
            flex: 1;
            min-width: 100px;
            margin: 0;
            padding: calc(.5em - 1px) 0;
            height: auto;
        }
        
        #label-quick-selector .search-results {
            background: var(--white, #fff);
            border: 1px solid var(--primary, #1FB6F6);
            border-top: none;
            border-radius: 0 0 4px 4px;
            max-height: 200px;
            overflow-y: auto;
        }
        
        #label-quick-selector .search-result-button {
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: .5rem;
            border: none;
            background: transparent;
            text-align: left;
            cursor: pointer;
            transition: background-color .15s ease;
        }
        
        #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-200, #f1f3f9) !important;
        }
        
        #label-quick-selector .tag {
            display: inline-flex;
            align-items: center;
            padding: .25em .75em;
            border-radius: 4px;
            font-size: .75rem;
            font-weight: 600;
            line-height: 1;
            white-space: nowrap;
        }
        
        #label-quick-selector .tag.search-result {
            margin-right: .5rem;
        }
        
        #label-quick-selector .hint-text {
            color: var(--text-light, #7a7a7a);
            font-size: .875rem;
        }
        
        #label-quick-selector .delete {
            background: none;
            border: none;
            color: inherit;
            cursor: pointer;
            opacity: .7;
            transition: opacity .15s ease;
            padding: 0;
            margin-left: .5rem;
            width: 12px;
            height: 12px;
            position: relative;
        }
        
        #label-quick-selector .delete:hover {
            opacity: 1;
        }
        
        #label-quick-selector .delete::before,
        #label-quick-selector .delete::after {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 8px;
            height: 1px;
            background: currentColor;
            transform: translate(-50%, -50%) rotate(45deg);
        }
        
        #label-quick-selector .delete::after {
            transform: translate(-50%, -50%) rotate(-45deg);
        }
        
        /* Handle dark mode if present */
        :root.dark #label-quick-selector .input-wrapper {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-results {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-100, #21262D// ==UserScript==
// @name         Vikunja Quick Project Switch (try.vikunja.io)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Adds a button to quickly move tasks to other projects, add tags, and delete tasks in Vikunja.
// @author       Yandrik
// @match        https://try.vikunja.io/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// ==/UserScript==

// USAGE:
// install in Tampermonkey (create new, copy -> paste)
// There, in the editor:
// ENTER YOUR VICUNJA INSTANCE URL IN @match!

(function() {
    'use strict';

    const debug = false;
    let projects = [];
    let allLabels = [];

    // Function to log messages if debug is true
    function log(...messages) {
        if (debug) {
            console.log('[Vikunja Quick Switch]', ...messages);
        }
    }

    // Function to get the JWT token from local storage
    function getJwtToken() {
        return localStorage.getItem('token');
    }

    // Function to fetch all projects
    function fetchProjects() {
	log('fetchProjects');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: '/api/v1/projects',
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 200) {
                    projects = JSON.parse(response.responseText);
                    log('Projects loaded:', projects);
                } else {
                    log('Failed to fetch projects:', response.statusText);
                }
            },
            onerror: function(error) {
                log('Error fetching projects:', error);
            }
        });
    }

    // Function to fetch all labels
    function fetchAllLabels() {
        log('fetchAllLabels');
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        allLabels = JSON.parse(response.responseText);
                        log('All labels loaded:', allLabels);
                        resolve(allLabels);
                    } else {
                        log('Failed to fetch labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching labels:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to fetch labels for a specific task
    function fetchTaskLabels(taskId) {
        log('fetchTaskLabels', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve([]);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200) {
                        const taskLabels = JSON.parse(response.responseText);
                        log('Task labels loaded:', taskLabels);
                        resolve(taskLabels);
                    } else {
                        log('Failed to fetch task labels:', response.statusText);
                        resolve([]);
                    }
                },
                onerror: function(error) {
                    log('Error fetching task labels:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to create a new label
    function createLabel(title, hexColor = 'fd8a09') {
        log('createLabel', title, hexColor);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(null);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: '/api/v1/labels',
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    title: title,
                    hex_color: hexColor
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        const newLabel = JSON.parse(response.responseText);
                        log('Label created:', newLabel);
                        allLabels.push(newLabel);
                        resolve(newLabel);
                    } else {
                        log('Failed to create label:', response.statusText);
                        resolve(null);
                    }
                },
                onerror: function(error) {
                    log('Error creating label:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to add a label to a task
    function addLabelToTask(taskId, labelId) {
        log('addLabelToTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'PUT',
                url: `/api/v1/tasks/${taskId}/labels`,
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    label_id: labelId
                }),
                onload: function(response) {
                    if (response.status === 200 || response.status === 201) {
                        log('Label added to task successfully');
                        resolve(true);
                    } else {
                        log('Failed to add label to task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error adding label to task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to remove a label from a task
    function removeLabelFromTask(taskId, labelId) {
        log('removeLabelFromTask', taskId, labelId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return Promise.resolve(false);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'DELETE',
                url: `/api/v1/tasks/${taskId}/labels/${labelId}`,
                headers: {
                    'Authorization': `Bearer ${token}`
                },
                onload: function(response) {
                    if (response.status === 200 || response.status === 204) {
                        log('Label removed from task successfully');
                        resolve(true);
                    } else {
                        log('Failed to remove label from task:', response.statusText);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    log('Error removing label from task:', error);
                    reject(error);
                }
            });
        });
    }

    // Function to perform fuzzy search on labels
    function fuzzySearch(needle, haystack) {
        if (!needle) return true;
        
        const needleLower = needle.toLowerCase();
        const haystackLower = haystack.toLowerCase();
        
        // Simple fuzzy search: check if all characters from needle exist in order in haystack
        let needleIndex = 0;
        for (let i = 0; i < haystackLower.length && needleIndex < needleLower.length; i++) {
            if (haystackLower[i] === needleLower[needleIndex]) {
                needleIndex++;
            }
        }
        return needleIndex === needleLower.length;
    }

    // Function to get text color based on background color
    function getTextColor(hexColor) {
        // Remove # if present
        const color = hexColor.replace('#', '');
        const r = parseInt(color.substr(0, 2), 16);
        const g = parseInt(color.substr(2, 2), 16);
        const b = parseInt(color.substr(4, 2), 16);
        
        // Calculate luminance
        const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        
        // Return dark or light text color based on background luminance
        return luminance > 0.5 ? 'hsl(215, 27.9%, 16.9%)' : 'hsl(220, 14.3%, 95.9%)';
    }

    // Function to create and show the label selector
    async function showLabelSelector(event, taskElement) {
        const existingSelector = document.getElementById('label-quick-selector');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();
        const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();

        // Fetch data in parallel - always refresh labels
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always fetch fresh labels
        ]);

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'label-quick-selector';
        selectorContainer.className = 'multiselect has-search-results';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.right - 350}px`; // Position to the left of the button
        selectorContainer.style.width = '350px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.tabIndex = -1;

        const controlDiv = document.createElement('div');
        controlDiv.className = 'control';

        const inputWrapper = document.createElement('div');
        inputWrapper.className = 'input-wrapper input has-multiple';

        // Add current task labels as chips
        taskLabels.forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                // Refresh the selector after removing a label, but don't close it
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.appendChild(chip);
        });

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.className = 'input';
        searchInput.placeholder = 'Type to add a label…';
        searchInput.style.border = 'none';
        searchInput.style.outline = 'none';
        searchInput.style.backgroundColor = 'transparent';
        
        inputWrapper.appendChild(searchInput);
        controlDiv.appendChild(inputWrapper);
        selectorContainer.appendChild(controlDiv);

        const searchResults = document.createElement('div');
        searchResults.className = 'search-results';
        selectorContainer.appendChild(searchResults);

        let selectedIndex = -1;

        function updateSearchResults(query = '') {
            searchResults.innerHTML = '';
            selectedIndex = -1;

            // Filter available labels (excluding already attached ones) - use fresh taskLabels
            const currentTaskLabels = Array.from(inputWrapper.querySelectorAll('.tag')).map(chip => ({
                title: chip.querySelector('span').textContent
            }));
            const attachedLabelTitles = currentTaskLabels.map(l => l.title);
            const availableLabels = allLabels.filter(label => 
                !attachedLabelTitles.includes(label.title) && 
                fuzzySearch(query, label.title)
            );

            // Show matching existing labels
            availableLabels.forEach((label, index) => {
                const resultButton = document.createElement('button');
                resultButton.type = 'button';
                resultButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                labelChip.style.background = `#${label.hex_color}`;
                labelChip.style.color = getTextColor(label.hex_color);
                
                const labelText = document.createElement('span');
                labelText.textContent = label.title;
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Click or press enter to select';
                
                resultButton.appendChild(labelSpan);
                resultButton.appendChild(hintText);
                
                resultButton.addEventListener('click', async () => {
                    await addLabelToTask(taskId, label.id);
                    refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                });
                
                searchResults.appendChild(resultButton);
            });

            // Show "Add as new label" option if query exists and no exact match among ALL labels (not just available ones)
            if (query.trim() && !allLabels.some(l => l.title.toLowerCase() === query.toLowerCase())) {
                const createButton = document.createElement('button');
                createButton.type = 'button';
                createButton.className = 'base-button base-button--type-button search-result-button is-fullwidth';
                
                const labelSpan = document.createElement('span');
                const labelChip = document.createElement('span');
                labelChip.className = 'tag search-result';
                
                const labelText = document.createElement('span');
                labelText.textContent = query.trim();
                labelChip.appendChild(labelText);
                labelSpan.appendChild(labelChip);
                
                const hintText = document.createElement('span');
                hintText.className = 'hint-text';
                hintText.textContent = 'Add this as new label';
                
                createButton.appendChild(labelSpan);
                createButton.appendChild(hintText);
                
                createButton.addEventListener('click', async () => {
                    const newLabel = await createLabel(query.trim());
                    if (newLabel) {
                        await addLabelToTask(taskId, newLabel.id);
                        refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing
                    }
                });
                
                searchResults.appendChild(createButton);
            }
        }

        // Keyboard navigation
        searchInput.addEventListener('keydown', (e) => {
            const results = searchResults.querySelectorAll('.search-result-button');
            
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
                updateSelection(results);
            } else if (e.key === 'ArrowUp') {
                e.preventDefault();
                selectedIndex = Math.max(selectedIndex - 1, -1);
                updateSelection(results);
            } else if (e.key === 'Enter') {
                e.preventDefault();
                if (selectedIndex >= 0 && results[selectedIndex]) {
                    results[selectedIndex].click();
                } else if (searchInput.value.trim()) {
                    const query = searchInput.value.trim();
                    
                    // Check if there's an exact match with an existing label first
                    const currentTaskLabels = Array.from(inputWrapper.querySelectorAll('.tag')).map(chip => ({
                        title: chip.querySelector('span').textContent
                    }));
                    const attachedLabelTitles = currentTaskLabels.map(l => l.title);
                    const exactMatch = allLabels.find(label => 
                        !attachedLabelTitles.includes(label.title) && 
                        label.title.toLowerCase() === query.toLowerCase()
                    );
                    
                    if (exactMatch) {
                        // Use existing label
                        addLabelToTask(taskId, exactMatch.id).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    } else {
                        // Create new label only if no exact match exists
                        createLabel(query).then(newLabel => {
                            if (newLabel) {
                                return addLabelToTask(taskId, newLabel.id);
                            }
                        }).then(() => {
                            refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
                        });
                    }
                }
            } else if (e.key === 'Escape') {
                selectorContainer.remove();
            }
        });

        function updateSelection(results) {
            results.forEach((result, index) => {
                if (index === selectedIndex) {
                    result.style.backgroundColor = 'var(--grey-200, #f1f3f9)';
                } else {
                    result.style.backgroundColor = '';
                }
            });
        }

        searchInput.addEventListener('input', (e) => {
            updateSearchResults(e.target.value);
        });

        updateSearchResults();
        document.body.appendChild(selectorContainer);

        // Focus the input
        searchInput.focus();

        // Close selector when clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to update the task's label display in the task list
    function updateTaskLabelsDisplay(taskElement, taskLabels) {
        const labelWrapper = taskElement.querySelector('.label-wrapper.labels');
        if (!labelWrapper) {
            log('No label wrapper found in task element');
            return;
        }

        // Get the Vue data attributes from an existing label if any exist
        const existingLabel = labelWrapper.querySelector('.tag');
        let outerDataAttrs = '';
        let innerDataAttrs = '';
        
        if (existingLabel) {
            // Extract data attributes from existing labels
            const outerAttrs = Array.from(existingLabel.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ');
            
            const innerSpan = existingLabel.querySelector('span');
            const innerAttrs = innerSpan ? Array.from(innerSpan.attributes)
                .filter(attr => attr.name.startsWith('data-v-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(' ') : '';
                
            outerDataAttrs = outerAttrs;
            innerDataAttrs = innerAttrs;
        }

        // Clear existing labels
        labelWrapper.innerHTML = '';

        // Add updated labels with proper Vue data attributes
        taskLabels.forEach(label => {
            const labelSpan = document.createElement('span');
            labelSpan.className = 'tag';
            labelSpan.style.background = `#${label.hex_color}`;
            labelSpan.style.color = getTextColor(label.hex_color);
            
            // Add Vue data attributes for proper styling
            if (outerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span class="tag" ${outerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelSpan.setAttribute(attr.name, attr.value);
                    }
                });
            }

            const labelText = document.createElement('span');
            labelText.textContent = label.title;
            
            // Add Vue data attributes to inner span
            if (innerDataAttrs) {
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = `<span ${innerDataAttrs}></span>`;
                const tempSpan = tempDiv.firstChild;
                Array.from(tempSpan.attributes).forEach(attr => {
                    if (attr.name.startsWith('data-v-')) {
                        labelText.setAttribute(attr.name, attr.value);
                    }
                });
            }
            
            labelSpan.appendChild(labelText);
            labelWrapper.appendChild(labelSpan);
        });
    }

    // Function to refresh the label selector without closing it
    async function refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement = null) {
        // Fetch fresh data
        const [taskLabels] = await Promise.all([
            fetchTaskLabels(taskId),
            fetchAllLabels() // Always refresh all labels too
        ]);
        
        const inputWrapper = selectorContainer.querySelector('.input-wrapper');
        
        // Clear existing chips (but keep the input)
        const existingChips = inputWrapper.querySelectorAll('.tag');
        existingChips.forEach(chip => chip.remove());
        
        // Re-add current task labels as chips
        taskLabels.forEach(label => {
            const chip = createLabelChip(label, taskId, () => {
                refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement);
            });
            inputWrapper.insertBefore(chip, searchInput);
        });

        // Update the task's label display in the task list
        if (taskElement) {
            updateTaskLabelsDisplay(taskElement, taskLabels);
        }
        
        // Clear search input and trigger update to refresh the search results
        searchInput.value = '';
        searchInput.dispatchEvent(new Event('input'));
        searchInput.focus();
    }

    // Function to create a label chip
    function createLabelChip(label, taskId, onRemove) {
        const chip = document.createElement('span');
        chip.className = 'tag';
        chip.style.background = `#${label.hex_color}`;
        chip.style.color = getTextColor(label.hex_color);
        chip.style.margin = '0 0 0 .5rem';

        const labelText = document.createElement('span');
        labelText.textContent = label.title;
        chip.appendChild(labelText);

        const deleteButton = document.createElement('button');
        deleteButton.type = 'button';
        deleteButton.className = 'base-button base-button--type-button delete is-small';
        deleteButton.addEventListener('click', async (e) => {
            e.stopPropagation();
            await removeLabelFromTask(taskId, label.id);
            if (onRemove) onRemove();
        });
        chip.appendChild(deleteButton);

        return chip;
    }

    // Function to move a task to a different project
    function moveTask(taskId, projectId, taskElement) {
        log('moveTask', taskId, projectId, taskElement);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        // First, fetch the current task details
        GM_xmlhttpRequest({
            method: 'GET',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status !== 200) {
                    log('Failed to fetch task details:', response.statusText);
                    alert('Failed to fetch task details before moving.');
                    return;
                }

                const task = JSON.parse(response.responseText);
                task.project_id = projectId;

		log('task to be updated:', task, 'projID:', projectId);

                // Now, update the task with the new project_id
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: `/api/v1/tasks/${taskId}`,
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json'
                    },
                    data: JSON.stringify(task),
                    onload: function(response) {
                        if (response.status === 200) {
                            log(`Task ${taskId} moved to project ${projectId}`);
                            // Remove the task from the list only if we're in a specific project view with a real project ID
                            const match = window.location.pathname.match(/\/projects\/(\d+)/);
                            if (match && parseInt(match[1]) >= 0) {
                                taskElement.remove();
                            } else {
                                // Update the project link and text if task remains visible
                                const projectLink = taskElement.querySelector('.task-project');
                                if (projectLink) {
                                    const targetProject = projects.find(p => p.id === projectId);
                                    if (targetProject) {
                                        projectLink.href = `/projects/${projectId}`;
                                        projectLink.textContent = targetProject.title;
                                    }
                                }
                            }
                        } else {
                            log(`Failed to move task:`);
                            log(response);
                            alert('Failed to move the task. Please try again.');
                        }
                    },
                    onerror: function(error) {
                        log('Error moving task:', error);
                        alert('An error occurred while moving the task.');
                    }
                });
            },
            onerror: function(error) {
                log('Error fetching task details:', error);
                alert('An error occurred while fetching task details.');
            }
        });
    }

    // Function to delete a task
    function deleteTask(taskId, taskElement) {
        log('deleteTask', taskId);
        const token = getJwtToken();
        if (!token) {
            log('JWT Token not found.');
            return;
        }

        GM_xmlhttpRequest({
            method: 'DELETE',
            url: `/api/v1/tasks/${taskId}`,
            headers: {
                'Authorization': `Bearer ${token}`
            },
            onload: function(response) {
                if (response.status === 204 || response.status === 200) { // Successfully deleted
                    log(`Task ${taskId} deleted`);
                    taskElement.remove();
                } else {
                    log(`Failed to delete task:`, response);
                    alert('Failed to delete the task. Please try again.');
                }
            },
            onerror: function(error) {
                log('Error deleting task:', error);
                alert('An error occurred while deleting the task.');
            }
        });
    }

    // Function to create and show the project selection combobox
    function showProjectSelector(event, taskElement) {
        const existingSelector = document.getElementById('project-quick-switcher');
        if (existingSelector) {
            existingSelector.remove();
        }

        const button = event.currentTarget;
        const rect = button.getBoundingClientRect();

        const selectorContainer = document.createElement('div');
        selectorContainer.id = 'project-quick-switcher';
        selectorContainer.style.position = 'absolute';
        selectorContainer.style.top = `${window.scrollY + rect.bottom}px`;
        selectorContainer.style.left = `${window.scrollX + rect.left}px`;
        selectorContainer.style.backgroundColor = '#333';
        selectorContainer.style.border = '1px solid #555';
        selectorContainer.style.borderRadius = '4px';
        selectorContainer.style.zIndex = '10000';
        selectorContainer.style.padding = '5px';

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = 'Search projects...';
        searchInput.style.width = '100%';
        searchInput.style.boxSizing = 'border-box';
        searchInput.style.marginBottom = '5px';
        selectorContainer.appendChild(searchInput);

        const projectList = document.createElement('ul');
        projectList.style.listStyle = 'none';
        projectList.style.margin = '0';
        projectList.style.padding = '0';
        projectList.style.maxHeight = '200px';
        projectList.style.overflowY = 'auto';

        function populateProjectList(filter = '') {
            projectList.innerHTML = '';
            const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(filter.toLowerCase()));

            filteredProjects.forEach(project => {
                const listItem = document.createElement('li');
                listItem.textContent = project.title;
                listItem.style.padding = '5px';
                listItem.style.cursor = 'pointer';

                listItem.addEventListener('mouseover', () => {
                    listItem.style.backgroundColor = '#555';
                });
                listItem.addEventListener('mouseout', () => {
                    listItem.style.backgroundColor = 'transparent';
                });

                listItem.addEventListener('click', () => {
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    moveTask(taskId, project.id, taskElement);
                    selectorContainer.remove();
                });
                projectList.appendChild(listItem);
            });
        }

        populateProjectList();
        selectorContainer.appendChild(projectList);

        searchInput.addEventListener('input', (e) => {
            populateProjectList(e.target.value);
        });

        document.body.appendChild(selectorContainer);

        // Close the selector if clicking outside
        document.addEventListener('click', function closeSelector(e) {
            if (!selectorContainer.contains(e.target) && e.target !== button) {
                selectorContainer.remove();
                document.removeEventListener('click', closeSelector);
            }
        });
    }

    // Function to add the control buttons to a task
    function addControlButtons(taskElement) {
        if (taskElement.querySelector('.control-btn')) {
            return; // Buttons already added
        }

        const favoriteButton = taskElement.querySelector('.favorite');
        if (favoriteButton) {
            const buttonContainer = document.createElement('span');
            buttonContainer.className = 'control-buttons';

            // '#' button
            const tagButton = document.createElement('button');
            tagButton.textContent = '#';
            tagButton.className = 'base-button base-button--type-button favorite control-btn';
            tagButton.style.marginRight = '5px';
            tagButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showLabelSelector(event, taskElement);
            });
            buttonContainer.appendChild(tagButton);

            // '@' button
            const switchButton = document.createElement('button');
            switchButton.textContent = '@';
            switchButton.className = 'base-button base-button--type-button favorite control-btn quick-switch-btn';
            switchButton.style.marginRight = '5px';
            switchButton.addEventListener('click', (event) => {
                event.stopPropagation();
                showProjectSelector(event, taskElement);
            });
            buttonContainer.appendChild(switchButton);

            // Delete button
            const deleteButton = document.createElement('button');
            deleteButton.textContent = 'X';
            deleteButton.className = 'base-button base-button--type-button favorite control-btn delete-btn';
            deleteButton.style.marginRight = '5px';
            let deleteTimeout = null;
            deleteButton.addEventListener('click', (event) => {
                event.stopPropagation();
                if (deleteButton.classList.contains('delete-confirm')) {
                    clearTimeout(deleteTimeout);
                    const taskId = taskElement.querySelector('a.task-link').href.split('/').pop();
                    deleteTask(taskId, taskElement);
                } else {
                    deleteButton.classList.add('delete-confirm');
                    // Blur the button to remove focus, so hover-out works correctly
                    deleteButton.blur();
                    deleteTimeout = setTimeout(() => {
                        deleteButton.classList.remove('delete-confirm');
                    }, 3000);
                }
            });
            buttonContainer.appendChild(deleteButton);

            favoriteButton.parentNode.insertBefore(buttonContainer, favoriteButton);
        }
    }

    // Use MutationObserver to detect when tasks are added to the DOM
    let observer = null;
    let currentTargetNode = null;
    let currentUrl = window.location.href;

    function createObserver() {
        return new MutationObserver((mutationsList, observer) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) { // ELEMENT_NODE
                            if (node.matches('.task.single-task')) {
                                addControlButtons(node);
                            }
                            node.querySelectorAll('.task.single-task').forEach(addControlButtons);
                        }
                    });
                }
            }
        });
    }

    // Start observing the main content area for changes
    function startObserver() {
        // First, disconnect any existing observer
        if (observer) {
            observer.disconnect();
        }
        
        const targetNode = document.querySelector('.card-content');
        if (targetNode && targetNode !== currentTargetNode) {
            currentTargetNode = targetNode;
            observer = createObserver();
            
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            
            // Initial run for tasks already on the page
            targetNode.querySelectorAll('.task.single-task').forEach(addControlButtons);
            log('Observer started for target:', targetNode);
        } else if (!targetNode) {
            // If the target node is not yet available, try again after a short delay
            currentTargetNode = null;
            setTimeout(startObserver, 500);
        }
    }

    // Add a periodic check to ensure the observer is still working
    function ensureObserverActive() {
        const newUrl = window.location.href;
        const targetNode = document.querySelector('.card-content');
        
        // Check if URL changed (client-side navigation)
        if (newUrl !== currentUrl) {
            log('URL changed, restarting observer');
            currentUrl = newUrl;
            currentTargetNode = null;
            startObserver();
            return;
        }
        
        // Check if target node changed or disappeared
        if (targetNode && targetNode !== currentTargetNode) {
            log('Target node changed, restarting observer');
            startObserver();
        } else if (!targetNode && currentTargetNode) {
            log('Target node removed, will restart when available');
            currentTargetNode = null;
            if (observer) {
                observer.disconnect();
                observer = null;
            }
            startObserver();
        }
    }

    // Initial setup
    log('Userscript loaded.');
    fetchProjects();
    fetchAllLabels();
    startObserver();
    
    // Check every 2 seconds if the observer is still active
    setInterval(ensureObserverActive, 2000);

    // Add some basic styling for our buttons and label selector
    GM_addStyle(`
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
            20%, 40%, 60%, 80% { transform: translateX(2px); }
        }
        .delete-btn.delete-confirm {
            color: red !important;
            animation: shake 0.5s !important;
        }
        .control-btn {
            text-align: center;
            width: 27px;
            transition: opacity .15s ease,color .15s ease;
            border-radius: 4px;
            opacity: 1;
        }
        @media (hover: hover) and (pointer: fine) {
            .task .control-btn {
                opacity: 0;
            }
            .task:hover .control-btn, .task:focus-within .control-btn {
                opacity: 1;
            }
        }

        /* Label selector styling to match native Vikunja */
        #label-quick-selector {
            position: relative;
        }
        
        #label-quick-selector .control {
            box-sizing: border-box;
            clear: both;
            font-size: 1rem;
            position: relative;
            text-align: inherit;
        }
        
        #label-quick-selector .input-wrapper {
            padding: .25rem !important;
            background: var(--white, #fff);
            border-color: var(--primary, #1FB6F6);
            border: 1px solid;
            border-radius: 4px 4px 0 0;
            flex-wrap: wrap;
            height: auto;
            display: flex;
            align-items: flex-start;
            min-height: 2.5em;
        }
        
        #label-quick-selector .input-wrapper .input {
            flex: 1;
            min-width: 100px;
            margin: 0;
            padding: calc(.5em - 1px) 0;
            height: auto;
        }
        
        #label-quick-selector .search-results {
            background: var(--white, #fff);
            border: 1px solid var(--primary, #1FB6F6);
            border-top: none;
            border-radius: 0 0 4px 4px;
            max-height: 200px;
            overflow-y: auto;
        }
        
        #label-quick-selector .search-result-button {
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: .5rem;
            border: none;
            background: transparent;
            text-align: left;
            cursor: pointer;
            transition: background-color .15s ease;
        }
        
        #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-200, #f1f3f9) !important;
        }
        
        #label-quick-selector .tag {
            display: inline-flex;
            align-items: center;
            padding: .25em .75em;
            border-radius: 4px;
            font-size: .75rem;
            font-weight: 600;
            line-height: 1;
            white-space: nowrap;
        }
        
        #label-quick-selector .tag.search-result {
            margin-right: .5rem;
        }
        
        #label-quick-selector .hint-text {
            color: var(--text-light, #7a7a7a);
            font-size: .875rem;
        }
        
        #label-quick-selector .delete {
            background: none;
            border: none;
            color: inherit;
            cursor: pointer;
            opacity: .7;
            transition: opacity .15s ease;
            padding: 0;
            margin-left: .5rem;
            width: 12px;
            height: 12px;
            position: relative;
        }
        
        #label-quick-selector .delete:hover {
            opacity: 1;
        }
        
        #label-quick-selector .delete::before,
        #label-quick-selector .delete::after {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 8px;
            height: 1px;
            background: currentColor;
            transform: translate(-50%, -50%) rotate(45deg);
        }
        
        #label-quick-selector .delete::after {
            transform: translate(-50%, -50%) rotate(-45deg);
        }
        
        /* Handle dark mode if present */
        :root.dark #label-quick-selector .input-wrapper {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-results {
            background: var(--grey-50, #0D1117);
            border-color: var(--primary, #1FB6F6);
        }
        
        :root.dark #label-quick-selector .search-result-button:hover {
            background-color: var(--grey-100, #21262D) !important;
        }
    `);
})();
) !important;
        }
    `);
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment