Last active
March 15, 2026 16:28
-
-
Save dnsflnv/f390ec6ba9a87c197782984b67a9c491 to your computer and use it in GitHub Desktop.
Google Tasks to TickTick App Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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