From 8d54cec9b55f71145b339fcd1f0ccbb4b9c0d551 Mon Sep 17 00:00:00 2001 From: sn00py1310 <61204194+sn00py1310@users.noreply.github.com> Date: Fri, 2 Jun 2023 16:53:18 +0200 Subject: [PATCH] Implementation of Basic features --- README.md | 34 ++++++++++++++ src/GTask.gs | 31 +++++++++++++ src/Habitica.gs | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Main.gs | 64 ++++++++++++++++++++++++++ src/Settings.gs | 59 ++++++++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 src/GTask.gs create mode 100644 src/Habitica.gs create mode 100644 src/Main.gs create mode 100644 src/Settings.gs diff --git a/README.md b/README.md index b1ce370..0b2768a 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ # Habitica Google Tasks Sync + +## Features +- Rate Limit efficient, only changes updated Google Tasks +- One-way synchronization from Google Tasks to Habitica + - Syncs tasks that have been unmarked again (with a limit of Habitica auto delete and only 30 latest marked tasks) + - Auto deletes Habitica tasks when Google Task got deleted +- Add default Tags for easy searching + +## Limitations +- No synchronization when a Task is moved to excluded Task Lists +- Subtasks a treated as their individual tasks +- No due date implementation +- If too many changes between the executions the script can crash. + +## Setup +### Installation +1. Go to the [latest release](./releases/latest) and download the .zip or .tar.gz file. +2. Create a Google App Script (GAS) Project [here](https://script.google.com). +3. Copy the content of the `src` folder in the downloaded release to the create GAS Project. +4. Get your Habitica API user and token from [here](https://habitica.com/user/settings/api). +5. Setup the project variables for the GAS Project under the settings, see [Setting](#settings). +6. In the GAS Project create a trigger for the function main. Set the repeating time to 5min. +7. Go to the main file and run the main method once, now you should get a pop-up to request access to your Task and to send external requests. +8. See the Tasks sync to Habitica. + + +### Settings +| Optional | Key | Value | +| --- | --- | --- | +| :x: | `habitica_api_key` | The Api key for your Habitica account | +| :x: | `habitica_api_user` | The Api user for your Habitica account | +| :heavy_check_mark: | `habitica_tags` | A comma (`,`) separated list of tags to add to the Tasks | +| :heavy_check_mark: | `excludedTaskLists` | A comma (`,`) separated list of Google Tasks List id to exclude | +| :heavy_check_mark: | `lastRun` | `System Settig` A timestamp to keep track of the last runs, to only get update Google Tasks | \ No newline at end of file diff --git a/src/GTask.gs b/src/GTask.gs new file mode 100644 index 0000000..7034f04 --- /dev/null +++ b/src/GTask.gs @@ -0,0 +1,31 @@ +function getAllTasksWithListData() { + const taskLists = Tasks.Tasklists.list(taskListReqSettigs); + if (!taskLists.items) { + console.log("No task lists found."); + return []; + } + + let allTasks = []; + for (const taskList of taskLists.items) { + if (getExcludedTaskLists().includes(taskList.id)) continue; + + console.info( + `Task list with title "${taskList.title}" and ID "${taskList.id}" was found.` + ); + let tasks = getAllGTasks(taskList.id); + + if (tasks.items.length !== 0) { + allTasks.push({ + id: taskList.id, + title: taskList.title, + tasks: tasks.items, + }); + } + } + + return allTasks; +} + +function getAllGTasks(taskListId) { + return Tasks.Tasks.list(taskListId, taskReqSettigs); +} diff --git a/src/Habitica.gs b/src/Habitica.gs new file mode 100644 index 0000000..7386d3d --- /dev/null +++ b/src/Habitica.gs @@ -0,0 +1,119 @@ +function fechtTasksApi(todoType) { + const response = UrlFetchApp.fetch( + `${baseApi}/tasks/user?type=${todoType}`, + getDefaultParams() + ); + + let resData = JSON.parse(response.getContentText()); + if (!resData.success) { + throw new Error( + `Request Error, ${response.getResponseCode()} - ${response.getContentText()}` + ); + } + return skipOtherTasks(resData.data); +} + +function getAllTasksApi() { + let allTasks = []; + for (const type of ["todos", "completedTodos"]) { + allTasks = allTasks.concat(fechtTasksApi(type)); + } + return allTasks; +} + +function skipOtherTasks(data) { + let tasks = []; + for (const t of data) { + if (!t.alias || !t.alias.startsWith(aliasPrefix)) continue; + tasks.push(t); + } + return tasks; +} + +function createTaskApi(payload) { + let params = getDefaultParams(); + params.method = "post"; + params.payload = JSON.stringify(payload); + + const response = UrlFetchApp.fetch(`${baseApi}/tasks/user`, params); + let resData = JSON.parse(response.getContentText()); + if (!resData.success) { + throw new Error( + `Request Error, ${response.getResponseCode()} - ${response.getContentText()}` + ); + } +} + +function updateTaskApi(payload, taskId) { + let params = getDefaultParams(); + params.method = "put"; + params.payload = JSON.stringify(payload); + + const response = UrlFetchApp.fetch(`${baseApi}/tasks/${taskId}`, params); + let resData = JSON.parse(response.getContentText()); + if (!resData.success) { + throw new Error( + `Request Error, ${response.getResponseCode()} - ${response.getContentText()}` + ); + } +} + +function deleteTaskApi(taskId) { + let params = getDefaultParams(); + params.method = "delete"; + + const response = UrlFetchApp.fetch(`${baseApi}/tasks/${taskId}`, params); + let resData = JSON.parse(response.getContentText()); + if (!resData.success) { + throw new Error( + `Request Error, ${response.getResponseCode()} - ${response.getContentText()}` + ); + } +} + +function scoreTaskApi(taskId, direction) { + let params = getDefaultParams(); + params.method = "post"; + + const response = UrlFetchApp.fetch( + `${baseApi}/tasks/${taskId}/score/${direction}`, + params + ); + + let resData = JSON.parse(response.getContentText()); + if (!resData.success) { + throw new Error( + `Request Error, ${response.getResponseCode()} - ${response.getContentText()}` + ); + } +} + +class CustomTask { + constructor(gTask, listTitle, listId) { + this.gTask = gTask; + this.listTitle = listTitle; + this.listId = listId; + } + + getId() { + return aliasPrefix + this.gTask.id; + } + + patchPayload() { + const g = this.gTask; + let p = {}; + p.type = "todo"; + p.text = `[${this.listTitle}] ${g.title} (GTasks)`; + if (habiticaTags) p.tags = habiticaTags; + + p.notes = `${g.notes ?? ""} (Last Updated: ${new Date().toLocaleString()})`; + + return p; + } + + createPayload() { + let p = this.patchPayload(); + p.alias = this.getId(); + return p; + } +} diff --git a/src/Main.gs b/src/Main.gs new file mode 100644 index 0000000..9c84ae7 --- /dev/null +++ b/src/Main.gs @@ -0,0 +1,64 @@ +function main() { + const lastRun = getLastRunTime(); + Logger.log(lastRun); + + try { + tasksHandler(); + updateLastRunTime(); + } catch (e) { + console.warn(e.message); + } +} + +function tasksHandler() { + let gTasksListsFiltered = getAllTasksWithListData(); + if (!gTasksListsFiltered) return; + + let hTaskDict = {}; + let hTasks = getAllTasksApi(); + for (const hTask of hTasks) { + hTaskDict[hTask.alias] = hTask; + } + + for (const gTasksWithListData of gTasksListsFiltered) { + const listId = gTasksWithListData.id; + const listTitle = gTasksWithListData.title; + const gTasks = gTasksWithListData.tasks; + + for (const gTask of gTasks) { + let alias = aliasPrefix + gTask.id; + + let customTask = new CustomTask(gTask, listTitle, listId); + if (hTaskDict[alias]) updateHTasks(customTask, hTaskDict[alias]); + else createHTask(customTask); + } + console.info(`Updated ${gTasks.length} tasks in ${listTitle} [${listId}]`); + } +} + +function updateHTasks(customTask, hTask) { + if (customTask.gTask.deleted) { + deleteTaskApi(customTask.getId()); + return; + } + + updateTaskApi(customTask.patchPayload(), customTask.getId()); + taskScorer(customTask, hTask.completed); +} + +function createHTask(customTask) { + if (customTask.gTask.deleted) return; + + createTaskApi(customTask.createPayload()); + taskScorer(customTask, false); +} + +function taskScorer(customTask, hTaskCompleted) { + const gTask = customTask.gTask; + let gTaskCompleted = gTask.status == "completed"; + + if (gTaskCompleted != hTaskCompleted) { + let direction = gTaskCompleted ? "up" : "down"; + scoreTaskApi(customTask.getId(), direction); + } +} diff --git a/src/Settings.gs b/src/Settings.gs new file mode 100644 index 0000000..7a3e823 --- /dev/null +++ b/src/Settings.gs @@ -0,0 +1,59 @@ +const properties = PropertiesService.getScriptProperties(); +const aliasPrefix = "GTasks_"; + +function getLastRunTime() { + return new Date(properties.getProperty("lastRun")); +} + +function updateLastRunTime() { + properties.setProperty("lastRun", new Date()); +} + +const excludedTaskLists = properties.getProperty("excludedTaskLists") + ? properties.getProperty("excludedTaskLists").split(",") + : []; +function getExcludedTaskLists() { + return excludedTaskLists; +} + +const habiticaTags = properties.getProperty("habitica_tags") + ? properties.getProperty("habitica_tags").split(",") + : []; + +function clearAllProperties() { + PropertiesService.getUserProperties().deleteAllProperties(); + PropertiesService.getScriptProperties().deleteAllProperties(); +} + +const taskListReqSettigs = { + showHidden: true, + maxResults: 100, +}; + +const taskReqSettigs = { + showHidden: true, + showDeleted: true, + maxResults: 100, + updatedMin: getLastRunTime().toISOString(), +}; + +const baseApi = "https://habitica.com/api/v4"; + +const baseHeaders = { + "x-api-key": properties.getProperty("habitica_api_key"), + "x-api-user": properties.getProperty("habitica_api_user"), + "content-type": "application/json", +}; + +const defaultParams = { + headers: baseHeaders, + muteHttpExceptions: true, +}; + +function getDefaultParams() { + return copyObj(defaultParams); +} + +function copyObj(obj) { + return JSON.parse(JSON.stringify(obj)); +}