/** * VikunjaClient - Low-level API wrapper for Vikunja Task Management */ /** * Helper to ensure the base URL always points to the Vikunja API root (/api/v1) * @return {string} Normalized API base URL */ function getVikunjaApiBaseUrl_() { const config = getVikunjaConfig(); let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash if (!baseUrl.endsWith('/api/v1')) { baseUrl += '/api/v1'; } return baseUrl; } function vikunjaRequest_(method, path, payload = null) { const apiBaseUrl = getVikunjaApiBaseUrl_(); const normalizedPath = path.startsWith('/') ? path : '/' + path; const url = `${apiBaseUrl}${normalizedPath}`; console.log(`Vikunja Request: [${method}] ${url}`); const config = getVikunjaConfig(); if (!config.token) { throw new Error('Vikunja Token is missing from configuration.'); } const options = { method: method, headers: { 'Authorization': `Bearer ${config.token}`, 'Content-Type': 'application/json' }, muteHttpExceptions: true }; console.log('Vikunja Auth Header Present:', !!options.headers.Authorization); if (payload) { options.payload = JSON.stringify(payload); } try { const response = UrlFetchApp.fetch(url, options); const code = response.getResponseCode(); const rawBody = response.getContentText(); let json = null; if (rawBody) { try { json = JSON.parse(rawBody); } catch (e) { console.warn(`Non-JSON response received from Vikunja: ${rawBody.substring(0, 200)}...`); } } if (code >= 200 && code < 300) { return { ok: true, code: code, data: json, rawBody: rawBody, message: 'Request successful' }; } return { ok: false, code: code, data: json, rawBody: rawBody, message: json && json.message ? json.message : `Vikunja API Error ${code}` }; } catch (e) { return { ok: false, code: 500, data: null, rawBody: null, message: `Vikunja Connection Error: ${e.message}` }; } } function getVikunjaTask(taskId) { return vikunjaRequest_('GET', `/tasks/${taskId}`); } function createVikunjaComment(taskId, commentText) { const payload = { comment: commentText }; const path = `/tasks/${taskId}/comments`; console.log(`Vikunja Comment Create Attempt: [PUT] ${path} | Payload: ${JSON.stringify(payload)}`); const res = vikunjaRequest_('PUT', path, payload); console.log(`Vikunja Comment Response: Code ${res.code} | Body: ${res.rawBody}`); return res; } function updateVikunjaTask(taskId, data) { console.log(`Vikunja Task Update Attempt: [POST] /tasks/${taskId} | Payload: ${JSON.stringify(data)}`); const res = vikunjaRequest_('POST', `/tasks/${taskId}`, data); console.log(`Vikunja Task Update Response: Code ${res.code} | Body: ${res.rawBody}`); return res; } function setVikunjaTaskDone(taskId, isDone) { return updateVikunjaTask(taskId, { done: isDone }); } function createVikunjaTask(taskData) { const projectId = taskData.projectId || taskData.project_id; if (!projectId) { return { ok: false, message: 'projectId is required to create a task in Vikunja.' }; } // Construct full payload including priority and description const payload = { title: taskData.title, description: taskData.description || '', priority: typeof taskData.priority === 'number' ? taskData.priority : 0 }; const endpoint = `/projects/${projectId}/tasks`; console.log(`Vikunja Create Attempt: [PUT] ${endpoint} | Payload: ${JSON.stringify(payload)}`); // Using PUT as per instance-specific evidence (POST returns 405) const res = vikunjaRequest_('PUT', endpoint, payload); console.log(`Vikunja Response: Code ${res.code} | Body: ${res.rawBody}`); if (res.ok && res.data && res.data.id) { return { ok: true, code: res.code, data: res.data, rawBody: res.rawBody, message: 'Task created successfully' }; } return { ok: false, code: res.code || 500, data: res.data || null, rawBody: res.rawBody || '', message: `Task creation failed: ${res.message || 'Unknown error'}. Raw body: ${res.rawBody || 'No body'}` }; } function searchVikunjaTasks(query) { // API v1 /tasks returns list of tasks. // We fetch and filter client-side to avoid complex filter syntax errors. const res = vikunjaRequest_('GET', '/tasks'); if (!res.ok) return res; const filtered = (res.data || []).filter(t => t.title && t.title.toLowerCase().includes(query.toLowerCase()) ); return { ok: true, data: filtered }; } function getLabelIdByName_(labelName) { const res = vikunjaRequest_('GET', '/labels'); if (!res.ok) return null; // Vikunja labels typically use 'title' for the display name const label = (res.data || []).find(l => (l.title && l.title.toLowerCase() === labelName.toLowerCase()) || (l.name && l.name.toLowerCase() === labelName.toLowerCase()) ); return label ? label.id : null; } function createVikunjaLabel(labelName) { // Using PUT as per instance evidence for label creation const payload = { title: labelName }; console.log(`Vikunja Label Create Attempt: [PUT] /labels | Payload: ${JSON.stringify(payload)}`); const res = vikunjaRequest_('PUT', '/labels', payload); if (res.ok && res.data && res.data.id) { return { ok: true, id: res.data.id, code: res.code }; } console.error(`Vikunja Label Create Failed: Code ${res.code} | Body: ${res.rawBody}`); return { ok: false, message: res.message || 'Failed to create label', code: res.code }; } function addVikunjaLabel(taskId, labelName) { let labelId = getLabelIdByName_(labelName); // Auto-create label if it doesn't exist if (!labelId) { const createRes = createVikunjaLabel(labelName); if (!createRes.ok) { return { ok: false, message: `Label resolution failed: ${createRes.message}`, code: createRes.code }; } labelId = createRes.id; } // Attach label to task using PUT as per instance evidence const payload = { label_id: labelId }; console.log(`Vikunja Label Attach Attempt: [PUT] /tasks/${taskId}/labels | Payload: ${JSON.stringify(payload)}`); const res = vikunjaRequest_('PUT', `/tasks/${taskId}/labels`, payload); if (!res.ok) { console.error(`Vikunja Label Attach Failed: Code ${res.code} | Body: ${res.rawBody}`); } return res; } function deleteVikunjaTask(taskId) { return vikunjaRequest_('DELETE', `/tasks/${taskId}`); }