Last active
April 8, 2024 16:43
-
-
Save alexberkowitz/8ddd4fcbfe077c37389b6c0218f978c2 to your computer and use it in GitHub Desktop.
Todoist Widget for Scriptable
This file contains 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
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: deep-blue; icon-glyph: magic; | |
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: deep-blue; icon-glyph: clipboard-check; | |
/****************************************************************************** | |
* 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. | |
// Tasks can be color-coded by project. | |
// Tasks that are given a label of "Blocked" will be dimmed. | |
// | |
// Check the configuration below to enable/disable features. | |
// | |
// 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! | |
/****************************************************************************** | |
/* Constants and Configurations | |
/* DON'T SKIP THESE!!! | |
*****************************************************************************/ | |
// 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'; | |
// Your Todoist API token | |
// This can be found by logging into the website, | |
// opening the settings menu, and selecting | |
// "Integrations" -- your key will be listed at | |
// the bottom of the page. | |
const API_TOKEN = 'YOUR_TODOIST_API_TOKEN'; | |
// Filter must be converted into a URL string | |
// Note that this is the actual filter query, NOT | |
// the filter ID. You can convert it to a URL | |
// string using any online URL encoder service. | |
const FILTER = ''; | |
// Font size for all text | |
// The title will be slightly larger | |
const FONT_SIZE = 12; | |
// Colors | |
const COLORS = { | |
bg: '#1E1E1E', // Main background | |
title_bg: '#2c2c2c', // Title background | |
text: '#cccccc', | |
p1: '#ff7066', | |
p2: '#ff9a14', | |
p3: '#5297ff', | |
p4: '#444444', | |
overdue: '#888888', // Only applies to non-emoji characters | |
empty_message: '#888888' | |
}; | |
// Use a background image for the widget | |
// Put the image in the Scriptable folder in your iCloud files and enter the file name below. | |
const USE_BG_IMAGE = true; | |
const BG_IMAGE = 'bgMedTop.jpg'; | |
// Project colors | |
const SHOW_PROJECT_COLORS = true; | |
// These are the default project colors provided by Todoist, | |
// but you can change them if you'd like. | |
const PROJECT_COLORS = { | |
"berry_red": "#b8256f", | |
"red": "#db4035", | |
"orange": "#ff9933", | |
"yellow": "#fad000", | |
"olive_green": "#afb83b", | |
"lime_green": "#7ecc49", | |
"green": "#299438", | |
"mint_green": "#6accbc", | |
"teal": "#158fad", | |
"sky_blue": "#14aaf5", | |
"light_blue": "#96c3eb", | |
"blue": "#4073ff", | |
"grape": "#884dff", | |
"violet": "#af38eb", | |
"lavender": "#eb96eb", | |
"magenta": "#e05194", | |
"salmon": "#ff8d85", | |
"charcoal": "#808080", | |
"grey": "#b8b8b8", | |
"taupe": "#ccac93" | |
}; | |
// Padding between text and sides | |
const PADDING = 4; | |
// Spaxe between todo lines | |
const LINE_SPACING = 2.5; | |
const SHOW_TITLE_BAR = false; | |
const TITLE = 'Things to do today:'; | |
const EMPTY_MESSAGE = 'ALL DONE FOR TODAY'; | |
// Max number of todos per column | |
const MAX_LINES = 8; | |
// Max number of columns | |
const MAX_COLUMNS = 2; | |
// What order to put the todos in | |
// Available options: | |
// "default" - Unsorted, todos will be displayed in the order they are received | |
// "alphabetical" - Alphabetical sorting | |
// "special" - Alphabetical sorting with grouping by priority | |
const SORTING_METHOD = "special"; | |
// Whether or not to show the "+" button to add a task | |
const SHOW_ADD_BUTTON = false; | |
// Whether or not to show colored priority dots next to todos | |
const SHOW_PRIORITY_COLORS = true; | |
const PRIORITY_COLOR_STYLE = "background"; // “background” or “dots” | |
// Whether or not to highlight overdue tasks | |
const HIGHLIGHT_OVERDUE = false; | |
const OVERDUE_SYMBOL = "⚠"; | |
// Whether or not to dim todos that have the "Blocked" label | |
const DIM_BLOCKED_TODOS = true; | |
/****************************************************************************** | |
* Initial Setups | |
*****************************************************************************/ | |
// Get current date and time | |
const updatedAt = new Date().toLocaleString(); | |
// Import and setup Cache | |
const Cache = importModule('Cache'); | |
const cache = new Cache('TodoistItems'); | |
// Fetch data and create widget | |
const data = await fetchData(); | |
const projects = await fetchProjects(); | |
const widget = createWidget(data); | |
Script.setWidget(widget); | |
// widget.presentMedium(); // Used for testing purposes only | |
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); | |
if( USE_BG_IMAGE ){ | |
let fm = FileManager.iCloud() | |
let image = fm.readImage(`${fm.documentsDirectory()}/${BG_IMAGE}`); | |
widget.backgroundImage = image; | |
} | |
// 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; | |
if( SHOW_TITLE_BAR ){ | |
//-- Title Bar --\\ | |
const titleStack = contentStack.addStack(); | |
titleStack.backgroundColor = new Color(COLORS.title_bg); | |
const titleVertOffset = FONT_SIZE/4; // 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 = new Color(COLORS.text); | |
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"; | |
} | |
} else { | |
const upperSpacer = contentStack.addStack(); | |
upperSpacer.size = new Size(0,1); | |
} | |
//-- Todo Items --\\ | |
if( !!data ){ // Error response handling | |
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, PADDING, 0, PADDING); | |
if( SHOW_TITLE_BAR ){ | |
todoContainer.setPadding(0, PADDING, 0, PADDING); | |
} | |
todoContainer.spacing = LINE_SPACING; // Column spacing | |
todoContainer.url = "todoist://"; | |
// Item list | |
switch( SORTING_METHOD ){ | |
case "alphabetical": | |
todos.sort((a, b) => (a.content.toLowerCase() > b.content.toLowerCase()) ? 1 : -1); // Sort todos alphabetically | |
break; | |
case "special": | |
todos.sort((a, b) => (a.content.toLowerCase() < b.content.toLowerCase()) ? 1 : -1); // Sort todos reverse alphabetically | |
todos.sort((a, b) => (a.priority < b.priority) ? 1 : -1); // Sort todos by priority | |
break; | |
default: | |
break; | |
} | |
let columns = Math.min(Math.ceil(todos.length / MAX_LINES), MAX_COLUMNS); // Number of columns to create | |
let columnList = []; // Array to store columns | |
for( let i = 0; i < columns; i++ ){ | |
columnList[i] = todoContainer.addStack(); | |
columnList[i].layoutVertically(); | |
columnList[i].spacing = LINE_SPACING; | |
let sliceStart = MAX_LINES * i; | |
let sliceEnd = MAX_LINES * (i+1); | |
for(const item of todos.slice(sliceStart, sliceEnd)){ | |
addTodo(columnList[i], item, i, columns); | |
} | |
} | |
const itemSpacer = contentStack.addStack(); | |
itemSpacer.layoutHorizontally(); | |
itemSpacer.addSpacer(); | |
itemSpacer.addSpacer(); | |
} 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(COLORS.empty_message); | |
emptyMessage.font = Font.systemFont(FONT_SIZE); | |
emptyMessage.centerAlignText(); | |
emptyMessageStack.addSpacer(); | |
contentStack.addSpacer(); | |
} | |
} else { // Error message | |
contentStack.addSpacer(); | |
const emptyMessageStack = contentStack.addStack(); | |
emptyMessageStack.addSpacer(); | |
const emptyMessage = emptyMessageStack.addText('There was an error fetching tasks.\nPlease try again later.'); | |
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(); | |
if( !!todos ){ | |
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 items from the cache | |
let cachedTodos = await cache.read(CACHE_TODOS); | |
return { | |
cachedTodos, | |
lastUpdated, | |
}; | |
} else { | |
// If unable to fetch todos, try to read from | |
// the cache and return those instead. | |
// Read todos from the cache | |
let cachedTodos = await cache.read(CACHE_TODOS); | |
return { cachedTodos } || false; | |
} | |
} | |
/****************************************************************************** | |
* Helper Functions | |
*****************************************************************************/ | |
//------------------------------------- | |
// Todoist Helper Functions | |
//------------------------------------- | |
/* | |
* Fetch the todo items from Todoist | |
*/ | |
async function fetchToDos() { | |
const url = "https://api.todoist.com/rest/v2/tasks?filter=" + FILTER; | |
const headers = { | |
"Authorization": "Bearer " + API_TOKEN | |
}; | |
const data = await fetchJson(url, headers); | |
// Preview the data response for testing purposes | |
// let str = JSON.stringify(data, null, 2); | |
// await QuickLook.present(str); | |
return data || false; | |
} | |
/* | |
* Fetch the projects from Todoist | |
*/ | |
async function fetchProjects() { | |
const url = "https://api.todoist.com/rest/v2/projects"; | |
const headers = { | |
"Authorization": "Bearer " + API_TOKEN | |
}; | |
const data = await fetchJson(url, headers); | |
// Preview the data response for testing purposes | |
// let str = JSON.stringify(data, null, 2); | |
// await QuickLook.present(str); | |
return data || false; | |
} | |
/* | |
* Add an item to the given stack | |
* This is the main todo creation function. | |
*/ | |
function addTodo(stack, item, currentColumn, totalColumns){ | |
// Create item stack | |
let todoItem = stack.addStack(); | |
todoItem.centerAlignContent(); | |
todoItem.spacing = LINE_SPACING; | |
todoItem.cornerRadius = 4; | |
// Add colored priority indicator | |
if( SHOW_PRIORITY_COLORS ){ | |
if( PRIORITY_COLOR_STYLE == "background" ){ | |
todoItem.setPadding(LINE_SPACING/3, PADDING, LINE_SPACING/3, PADDING); | |
let priorityBackground = new LinearGradient(); // Create the background gradient | |
priorityBackground.locations = [0,1]; | |
priorityBackground.startPoint = new Point(0,1); | |
priorityBackground.endPoint = new Point(1,1); | |
switch( item.priority ){ | |
case 4: | |
priorityBackground.colors = [new Color(COLORS.p1, 0.5), new Color(COLORS.p1, 0)]; | |
break; | |
case 3: | |
priorityBackground.colors = [new Color(COLORS.p2, 0.5), new Color(COLORS.p2, 0)]; | |
break; | |
case 2: | |
priorityBackground.colors = [new Color(COLORS.p3, 0.5), new Color(COLORS.p3, 0)]; | |
break; | |
default: | |
break; | |
} | |
todoItem.backgroundGradient = priorityBackground; // Apply the background gradient | |
} else { | |
todoItem.setPadding(LINE_SPACING/3, PADDING/2, LINE_SPACING/3, PADDING/2); | |
let todoBullet = todoItem.addText(String("●")); | |
todoBullet.font = Font.systemFont(FONT_SIZE); | |
todoBullet.textColor = new Color(PROJECT_COLORS[item.project_id] || "#ffffff"); | |
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 = new Color(COLORS.text); | |
todoContent.font = Font.systemFont(FONT_SIZE); | |
todoContent.lineLimit = 1; | |
todoItem.addSpacer(); | |
// If the item is blocked, give it a unique style | |
if( DIM_BLOCKED_TODOS && isLabelPresent(item.labels, "Blocked") ){ | |
todoContent.textOpacity = 0.5; | |
} | |
// Highlight overdue tasks | |
if( HIGHLIGHT_OVERDUE && !!item.due ){ | |
let today = new Date().setHours(0,0,0,0); | |
let dueDate = new Date(item.due.date).setHours(24,0,0,0); | |
if( dueDate < today ){ // The task is overdue | |
let todoOverdue = todoItem.addText(` ${OVERDUE_SYMBOL}`); | |
todoOverdue.font = Font.systemFont(FONT_SIZE); | |
todoOverdue.textColor = new Color(COLORS.overdue); | |
} | |
} | |
// Display the project color indicator | |
if( SHOW_PROJECT_COLORS && !!projects ){ | |
let todoProject = todoItem.addStack(); | |
todoProject.size = new Size(3, 10); | |
todoProject.cornerRadius = 1.5; | |
if( !!item.project_id && !projects.find(project => project.id === item.project_id).is_inbox_project ){ | |
let projectColorName = projects.find(project => project.id === item.project_id).color; | |
todoProject.backgroundColor = new Color(PROJECT_COLORS[projectColorName] || "#00000000"); | |
} else { | |
todoProject.backgroundColor = new Color("#00000000"); | |
} | |
} | |
} | |
/* | |
* 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. | |
if( !!labelList ){ | |
for( let i=0; i<labelList.length; i++ ){ | |
if( labelList[i].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(); | |
console.log(resp); | |
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
I haven't tried it yet, but you could probably use some regex to check for the presence of a markdown link and do stuff with it. Something like this: https://davidwells.io/snippets/regex-match-markdown-links