diff --git a/workflow/VikunjaClient.gs b/workflow/VikunjaClient.gs new file mode 100644 index 0000000..e5b8e17 --- /dev/null +++ b/workflow/VikunjaClient.gs @@ -0,0 +1,226 @@ +/** + * 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}`); +}