Skip to content

Instantly share code, notes, and snippets.

@alexberkowitz
Last active April 8, 2024 16:43
Show Gist options
  • Save alexberkowitz/8ddd4fcbfe077c37389b6c0218f978c2 to your computer and use it in GitHub Desktop.
Save alexberkowitz/8ddd4fcbfe077c37389b6c0218f978c2 to your computer and use it in GitHub Desktop.
Todoist Widget for Scriptable
// 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;
}
@alexberkowitz
Copy link
Author

Recommendation for parsing markdown links while using this?

A massive thank you for sharing this as well. 🙌

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment