Skip to content

Instantly share code, notes, and snippets.

@dnsflnv
Last active March 15, 2026 16:28
Show Gist options
  • Select an option

  • Save dnsflnv/f390ec6ba9a87c197782984b67a9c491 to your computer and use it in GitHub Desktop.

Select an option

Save dnsflnv/f390ec6ba9a87c197782984b67a9c491 to your computer and use it in GitHub Desktop.
Google Tasks to TickTick App Script
/**
* Google Tasks to TickTick Sync
*
* This script checks for new tasks in a specified Google Tasks list and creates them in the TickTick Inbox.
*
* SETUP INSTRUCTIONS:
* 1. Enable the "Tasks API" in the "Services" section of the Apps Script editor.
* 2. Set the following Script Properties (Project Settings > Script Properties):
* - TICKTICK_ACCESS_TOKEN: Your TickTick OAuth2 Access Token.
* - GOOGLE_TASKS_LIST_ID: (Optional) The ID of the Google Tasks list to watch. Defaults to the user's default list.
* 3. Set trigger - per hour, per 5 min (Project Setting > Trigger > time-based > syncTasks)
*
* HOW TO GET TICKTICK ACCESS TOKEN:
* You need to register an app at https://developer.ticktick.com/ to get a Client ID and Secret.
* Then perform an OAuth2 flow. For personal use, you can sometimes generate a token via their test tools or
* use a simple OAuth2 library flow.
*
* If you don't have a token yet, you can use the `logAuthUrl` function below (requires setting CLIENT_ID and CLIENT_SECRET in script properties)
* to start the flow, but you'll need a way to catch the callback.
*
* For simplicity, this script assumes you have a valid Access Token.
*/
// --- Configuration ---
const PROPERTIES = PropertiesService.getScriptProperties();
const TICKTICK_API_BASE = 'https://api.ticktick.com/open/v1/project';
/**
* Main function to sync tasks.
* Run this manually or set up a time-driven trigger (e.g., every 5 minutes).
*/
function syncTasks() {
const lastSyncTime = getLastSyncTime_();
const now = new Date();
console.log(`Starting sync. Last sync time: ${lastSyncTime}`);
// 1. Get Google Tasks
const listId = getTaskListId_();
if (!listId) {
console.error('Could not find a valid Google Tasks list.');
return;
}
// DEBUG: Log the list ID being used
console.log(`Checking Google Tasks List ID: ${listId}`);
// If it's the first run, we might not want to sync ALL old tasks.
if (!lastSyncTime) {
console.log('First run detected. Setting baseline sync time to now. No tasks will be synced this run.');
updateLastSyncTime_(now);
return;
}
// Load synced IDs to prevent duplicates (since we might rely on 'updated' time)
const syncedIds = getSyncedIds_();
const tasks = getNewGoogleTasks_(listId, lastSyncTime, syncedIds);
if (tasks.length === 0) {
console.log('No new tasks found.');
updateLastSyncTime_(now);
return;
}
console.log(`Found ${tasks.length} new tasks to sync.`);
// 2. Sync to TickTick
let successCount = 0;
for (const task of tasks) {
try {
createTickTickTask_(task);
successCount++;
// Mark as synced
syncedIds.push(task.id);
// Add a small delay to avoid rate limits if many tasks
Utilities.sleep(500);
} catch (e) {
console.error(`Failed to sync task "${task.title}": ${e.message}`);
}
}
console.log(`Sync complete. Successfully synced ${successCount}/${tasks.length} tasks.`);
updateLastSyncTime_(now);
saveSyncedIds_(syncedIds);
}
/**
* Fetches tasks from Google Tasks that were created (or updated) after the given time.
*/
function getNewGoogleTasks_(listId, sinceDate, syncedIds) {
let tasks = [];
let pageToken = null;
if (!sinceDate) {
throw new Error('sinceDate is required');
}
const sinceIso = sinceDate.toISOString();
console.log(`Fetching tasks updated since: ${sinceIso}`);
do {
const options = {
showCompleted: false,
showHidden: false,
updatedMin: sinceIso,
maxResults: 100,
pageToken: pageToken
};
try {
const response = Tasks.Tasks.list(listId, options);
const items = response.items || [];
console.log(`API returned ${items.length} items (raw).`);
for (const item of items) {
// Skip if already synced
if (syncedIds.includes(item.id)) {
console.log(`Skipping task "${item.title}" (Already synced)`);
continue;
}
// Determine effective creation time.
// Prefer 'published', fallback to 'updated'.
const published = item.published ? new Date(item.published) : null;
const updated = item.updated ? new Date(item.updated) : null;
// If published is missing, use updated.
const effectiveDate = published || updated;
console.log(`Checking task: "${item.title}" (Published: ${item.published}, Updated: ${item.updated}, Due: ${item.due})`);
if (effectiveDate && effectiveDate > sinceDate) {
tasks.push({
title: item.title,
notes: item.notes,
due: item.due, // RFC 3339 timestamp
id: item.id
});
} else {
console.log(` -> Skipped (Timestamp too old)`);
}
}
pageToken = response.nextPageToken;
} catch (e) {
console.error('Error fetching Google Tasks: ' + e.message);
break;
}
} while (pageToken);
return tasks;
}
/**
* Creates a task in TickTick Inbox.
*/
function createTickTickTask_(googleTask) {
const token = PROPERTIES.getProperty('TICKTICK_ACCESS_TOKEN');
if (!token) {
throw new Error('TICKTICK_ACCESS_TOKEN not set in Script Properties.');
}
// TickTick Open API: Create Task
// POST /open/v1/task
const url = 'https://api.ticktick.com/open/v1/task';
let formattedDueDate = undefined;
let isAllDay = false;
if (googleTask.due) {
// Google Tasks format: 2025-11-25T00:00:00.000Z
// For full-day tasks (no specific time), Google Tasks uses midnight UTC (00:00:00.000Z)
const dateObj = new Date(googleTask.due);
// Check if this is an all-day task (time is exactly 00:00:00 UTC)
const hours = dateObj.getUTCHours();
const minutes = dateObj.getUTCMinutes();
const seconds = dateObj.getUTCSeconds();
if (hours === 0 && minutes === 0 && seconds === 0) {
// This is an all-day task - format as date only (YYYY-MM-DD)
isAllDay = true;
const year = dateObj.getUTCFullYear();
const month = String(dateObj.getUTCMonth() + 1).padStart(2, '0');
const day = String(dateObj.getUTCDate()).padStart(2, '0');
formattedDueDate = `${year}-${month}-${day}T00:00:00+0000`;
} else {
// This is a timed task - include the time
const iso = dateObj.toISOString(); // 2025-11-25T00:00:00.000Z
formattedDueDate = iso.split('.')[0] + '+0000';
}
}
const payload = {
title: googleTask.title,
content: googleTask.notes || '',
dueDate: formattedDueDate,
isAllDay: isAllDay,
priority: 0 // 0: None, 1: Low, 3: Medium, 5: High
};
console.log(`Creating task with payload: ${JSON.stringify(payload)}`);
const options = {
method: 'post',
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${token}`
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
const content = response.getContentText();
if (code !== 200) {
throw new Error(`TickTick API Error (${code}): ${content}`);
}
console.log(`Created TickTick task: "${googleTask.title}"`);
}
/**
* Gets the ID of the Google Tasks list to watch.
* Defaults to the first list found if not specified.
*/
function getTaskListId_() {
const configuredId = PROPERTIES.getProperty('GOOGLE_TASKS_LIST_ID');
if (configuredId) return configuredId;
try {
const taskLists = Tasks.Tasklists.list();
if (taskLists.items && taskLists.items.length > 0) {
// Prefer a list named "Reminders" if it exists, otherwise the first one (usually "My Tasks")
const remindersList = taskLists.items.find(l => l.title === 'Reminders');
if (remindersList) {
console.log(`Using list: "${remindersList.title}" (ID: ${remindersList.id})`);
return remindersList.id;
}
const defaultList = taskLists.items[0];
console.log(`Using default list: "${defaultList.title}" (ID: ${defaultList.id})`);
return defaultList.id;
}
} catch (e) {
console.error('Error listing task lists: ' + e.message);
}
return null;
}
/**
* Helpers for Last Sync Time
*/
function getLastSyncTime_() {
const timeStr = PROPERTIES.getProperty('LAST_SYNC_TIME');
if (!timeStr) return null;
return new Date(timeStr);
}
function updateLastSyncTime_(date) {
PROPERTIES.setProperty('LAST_SYNC_TIME', date.toISOString());
}
/**
* Helpers for Synced IDs
*/
function getSyncedIds_() {
const json = PROPERTIES.getProperty('SYNCED_IDS');
if (!json) return [];
try {
return JSON.parse(json);
} catch (e) {
console.error('Error parsing SYNCED_IDS: ' + e.message);
return [];
}
}
function saveSyncedIds_(ids) {
// Keep only the last 100 IDs to avoid hitting property size limits
const MAX_IDS = 100;
const trimmedIds = ids.slice(-MAX_IDS);
PROPERTIES.setProperty('SYNCED_IDS', JSON.stringify(trimmedIds));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment