-
-
Save dillondcarter/8bd2234b0c3b7e5321c32a7d284188ed to your computer and use it in GitHub Desktop.
Todoist Widget for Scriptable
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
/****************************************************************************** | |
* Info | |
*****************************************************************************/ | |
// This script allows you to display your Todoist tasks as an iOS & MacOS | |
// widget using the app Scriptable. | |
// | |
// You can specify a filter to only display certain tasks. | |
// | |
// The script will display the priority of the tasks next to their | |
// content as colored dots. Tasks that are given a label of "Blocked" will | |
// be displayed as dimmed (check the configuration below to enable/disable features). | |
/****************************************************************************** | |
* Constants and Configurations | |
*****************************************************************************/ | |
// NOTE: This script uses the Cache script (https://github.com/kylereddoch/scriptable/src/Cache.js) | |
// Make sure to add the Cache script in Scriptable as well! | |
// Cache keys and default location | |
// If you have multiple instances of the script, make sure these values are unique for each one. | |
const CACHE_KEY_LAST_UPDATED = 'last_updated'; | |
const CACHE_TODOS = 'todoist_todos'; | |
// Get current date and time | |
const updatedAt = new Date().toLocaleString(); | |
// Layout and style configuration | |
const FONT_SIZE = 12; | |
const COLORS = { | |
bg: '#1f1f1f', | |
title_bg: '#2c2c2c', | |
p1: '#ff7066', | |
p2: '#ff9a14', | |
p3: '#5297ff', | |
p4: '#444444' | |
}; | |
const PADDING = 15; | |
const LINE_SPACING = 4.85; | |
const TITLE = 'Things to do today:'; | |
const EMPTY_MESSAGE = 'You\'re all done for today! 👍'; | |
// TODO: SET THESE VALUES | |
const API_TOKEN = 'YOUR_API_TOKEN_HERE'; | |
// Filter must be converted into a URL string | |
const FILTER = ""; | |
// Max number of lines | |
const MAX_LINES = 6; | |
// Max number of columns | |
const MAX_COLUMNS = 2; | |
// Whether or not to show the "+" to add a task | |
const SHOW_ADD_BUTTON = true; | |
// Whether or not to show colored priority dots next to todos | |
const SHOW_PRIORITY_COLORS = true; | |
// Whether or not to dim todos that have the "Blocked" label | |
const DIM_BLOCKED_TODOS = true; | |
/****************************************************************************** | |
* Initial Setups | |
*****************************************************************************/ | |
// Import and setup Cache | |
const Cache = importModule('Cache'); | |
const cache = new Cache('TodoistItems'); | |
// Fetch data and create widget | |
const data = await fetchData(); | |
const labels = await fetchLabels(); | |
const widget = createWidget(data); | |
Script.setWidget(widget); | |
Script.complete(); | |
/****************************************************************************** | |
* Main Functions (Widget and Data-Fetching) | |
*****************************************************************************/ | |
/** | |
* Main widget function. | |
* | |
* @param {} data The data for the widget to display | |
*/ | |
function createWidget(data) { | |
//-- Initialize the widget --\\ | |
const widget = new ListWidget(); | |
widget.backgroundColor = new Color(COLORS.bg);; | |
widget.setPadding(0, 0, 0, 0); | |
// Specifying the refreshAfterDate improves refresh times | |
let nextRefresh = Date.now() + 1000*30; | |
widget.refreshAfterDate = new Date(nextRefresh); | |
//-- Main Content Container --\\ | |
const contentStack = widget.addStack(); | |
contentStack.layoutVertically(); | |
contentStack.spacing = LINE_SPACING/2; | |
//-- Title Bar --\\ | |
const titleStack = contentStack.addStack(); | |
titleStack.backgroundColor = new Color(COLORS.title_bg); | |
const titleVertOffset = 1.5; // Helps to optically center the title in its container | |
// Title text | |
const titleTextStack = titleStack.addStack(); | |
titleTextStack.setPadding(PADDING/2+titleVertOffset, PADDING, PADDING/2-titleVertOffset, PADDING); | |
const titleText = titleTextStack.addText(TITLE); | |
titleText.textColor = Color.white(); | |
titleText.textOpacity = 0.75; | |
titleText.font = Font.boldSystemFont(FONT_SIZE+2); // Title is slightly larger than tasks | |
titleStack.addSpacer(); | |
// Add task button | |
if( SHOW_ADD_BUTTON ){ | |
const titleButtonContainerStack = titleStack.addStack(); | |
titleButtonContainerStack.setPadding(PADDING/2+titleVertOffset, PADDING, PADDING/2-titleVertOffset, PADDING); | |
const addButton = titleButtonContainerStack.addText("+"); | |
addButton.textColor = Color.white(); | |
addButton.font = Font.boldSystemFont(FONT_SIZE+2); | |
addButton.url = "todoist://addtask"; | |
} | |
//-- Todo Items --\\ | |
let todos = data.cachedTodos || []; // Get todo list | |
if( todos.length ){ // If there are items in the list | |
// Item container | |
const todoContainer = contentStack.addStack(); | |
todoContainer.layoutHorizontally(); | |
todoContainer.setPadding(PADDING/2, PADDING, PADDING/2, PADDING); | |
todoContainer.spacing = 15; // Column spacing | |
// Item list | |
todos.sort((a, b) => (a.content < b.content) ? 1 : -1); // Sort todos alphabetically | |
todos.sort((a, b) => (a.priority < b.priority) ? 1 : -1); // Sort todos by priority | |
let columns = Math.min(Math.ceil(todos.length / MAX_LINES), MAX_COLUMNS); // Number of columns to create | |
let columnObj = []; // Array to store columns | |
for( let i = 0; i < columns; i++ ){ | |
columnObj[i] = todoContainer.addStack(); | |
columnObj[i].layoutVertically(); | |
columnObj[i].spacing = LINE_SPACING; | |
let sliceStart = MAX_LINES * i; | |
let sliceEnd = MAX_LINES * (i+1); | |
for(const item of todos.slice(sliceStart, sliceEnd)){ | |
addTodo(columnObj[i], item); | |
} | |
} | |
} else { // Empty state when there are no items | |
contentStack.addSpacer(); | |
const emptyMessageStack = contentStack.addStack(); | |
emptyMessageStack.addSpacer(); | |
const emptyMessage = emptyMessageStack.addText(EMPTY_MESSAGE); | |
emptyMessage.textColor = new Color("#666666"); | |
emptyMessage.font = Font.boldSystemFont(FONT_SIZE+2); | |
emptyMessage.centerAlignText(); | |
emptyMessageStack.addSpacer(); | |
contentStack.addSpacer(); | |
} | |
widget.addSpacer(); // Push the content up | |
return widget; | |
} | |
/* | |
* Fetch pieces of data for the widget. | |
*/ | |
async function fetchData() { | |
// Get the todo data | |
const todos = await fetchToDos(); | |
cache.write(CACHE_TODOS, todos); | |
// Get last data update time (and set) | |
const lastUpdated = await getLastUpdated(); | |
cache.write(CACHE_KEY_LAST_UPDATED, new Date().getTime()); | |
// Read todos from the cache | |
let cachedTodos = await cache.read(CACHE_TODOS); | |
return { | |
cachedTodos, | |
lastUpdated, | |
}; | |
} | |
/****************************************************************************** | |
* Helper Functions | |
*****************************************************************************/ | |
//------------------------------------- | |
// Todoist Helper Functions | |
//------------------------------------- | |
/* | |
* Fetch the todo items from Todoist | |
*/ | |
async function fetchToDos() { | |
const url = "https://api.todoist.com/rest/v1/tasks?filter=" + FILTER; | |
const headers = { | |
"Authorization": "Bearer " + API_TOKEN | |
}; | |
const data = await fetchJson(url, headers); | |
if (!data) { | |
return 'No data found'; | |
} | |
// Preview the data response for testing purposes | |
// let str = JSON.stringify(data, null, 2); | |
// await QuickLook.present(str); | |
return data; | |
} | |
/* | |
* Fetch all labels from Todoist | |
*/ | |
async function fetchLabels() { | |
const url = "https://api.todoist.com/rest/v1/labels/"; | |
const headers = { | |
"Authorization": "Bearer " + API_TOKEN | |
}; | |
const data = await fetchJson(url, headers); | |
if (!data) { | |
return 'No data found'; | |
} | |
// Preview the data response for testing purposes | |
// let str = JSON.stringify(data, null, 2); | |
// await QuickLook.present(str); | |
return data; | |
} | |
/* | |
* Add an item to the given stack | |
*/ | |
function addTodo(stack, item){ | |
// Create item stack | |
let todoItem = stack.addStack(); | |
// Add bullet, colored to match priority | |
if( SHOW_PRIORITY_COLORS ){ | |
let todoBullet = todoItem.addText(String("● ")); | |
todoBullet.font = Font.systemFont(FONT_SIZE); | |
switch( item.priority ){ | |
case 4: | |
todoBullet.textColor = new Color(COLORS.p1); | |
break; | |
case 3: | |
todoBullet.textColor = new Color(COLORS.p2) | |
break; | |
case 2: | |
todoBullet.textColor = new Color(COLORS.p3) | |
break; | |
default: | |
todoBullet.textColor = new Color(COLORS.p4); | |
break; | |
} | |
} | |
// Add item content | |
let todoContent = todoItem.addText(item.content); | |
todoContent.textColor = Color.white(); | |
todoContent.font = Font.systemFont(FONT_SIZE); | |
todoContent.lineLimit = 1; | |
// If the item is blocked, give it a unique style | |
if( DIM_BLOCKED_TODOS && isLabelPresent(item.label_ids, "Blocked") ){ | |
todoContent.textOpacity = 0.5; | |
} | |
} | |
/* | |
* Search for a given label in the master labels list | |
*/ | |
function isLabelPresent(labelList, labelName){ | |
let labelIsPresent = false; | |
// For each label in the list, first find the matching entry in | |
// the master labels list. Then, check if its name matches the | |
// labelName passed into the function. | |
labelList.forEach(function(labelID){ | |
let labelInstance = labels.filter(obj => { | |
return obj.id === labelID; | |
}); | |
if( labelInstance[0].name == labelName ){ | |
labelIsPresent = true; | |
} | |
}); | |
return labelIsPresent; | |
} | |
//------------------------------------- | |
// Misc. Helper Functions | |
//------------------------------------- | |
/** | |
* Make a REST request and return the response | |
* | |
* @param {*} url URL to make the request to | |
* @param {*} headers Headers for the request | |
*/ | |
async function fetchJson(url, headers) { | |
try { | |
console.log(`Fetching url: ${url}`); | |
const req = new Request(url); | |
req.method = "get"; | |
req.headers = headers; | |
const resp = await req.loadJSON(); | |
return resp; | |
} catch (error) { | |
console.error(error); | |
} | |
} | |
/* | |
* Get the last updated timestamp from the Cache. | |
*/ | |
async function getLastUpdated() { | |
let cachedLastUpdated = await cache.read(CACHE_KEY_LAST_UPDATED); | |
if (!cachedLastUpdated) { | |
cachedLastUpdated = new Date().getTime(); | |
cache.write(CACHE_KEY_LAST_UPDATED, cachedLastUpdated); | |
} | |
return cachedLastUpdated; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment