Skip to content

Instantly share code, notes, and snippets.

@Tehnix
Last active April 12, 2024 23:33
Show Gist options
  • Save Tehnix/84fda408299a990be2c674589aaea5b8 to your computer and use it in GitHub Desktop.
Save Tehnix/84fda408299a990be2c674589aaea5b8 to your computer and use it in GitHub Desktop.
Linear iOS/macOS widget using Scriptable

Instructions

Using Scriptable we can create our own Linear widget to quickly glance our important issues. Original inspiration from tbekaert here.

This widget focuses on listing tasks based on a Custom View that you have setup, which allows you to control most things from the Linear side of things, and need minimal changes to this script (except for sorting and the placeholder variables).

  1. Install the Scriptable App
  2. Paste the contents of the Linear Widget.js file below
  3. Create a Personal API Key in Linear
  4. Replace the constants in the top of the file, at the very least LINEAR_API_KEY, VIEW_ID, DEFAULT_ASSIGNEE, and DEFAULT_PROJECT
    • You can get the various IDs and explore the API here
  5. Save the script
  6. Add the images from below into your iCloud Drive/Scriptable directory in a new images folder (or, set the COLOR_SCHEME to todoist to skip this)

You can find the archived macOS Scriptable App (still works) here https://web.archive.org/web/20230615065558/https://scriptable.app/mac-beta/ (Reddit thread about it).

Images

iCloud Drive/Scriptable/images/urgent-priority.png:

urgent-priority

iCloud Drive/Scriptable/images/high-priority.png:

high-priority

iCloud Drive/Scriptable/images/medium-priority.png:

medium-priority

iCloud Drive/Scriptable/images/low-priority.png:

low-priority

iCloud Drive/Scriptable/images/no-priority.png:

no-priority

Examples

iOS widget via the Scriptable App:

IMG_7585

macOS via the old/archived Scriptable App on macOS Sonoma:

Screenshot 2024-04-12 at 17 10 24
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: teal; icon-glyph: magic;
const LINEAR_API_KEY = "lin_api_....";
const WIDGET_SIZE = config.widgetFamily; // "small" | "medium" | "large"
const VIEW_ID = "....-....-....-...-....."; // Your Linear view ID.
const TEAM_ID = "LTI"; // Your Linear team ID.
const DEFAULT_ASSIGNEE = "displayName"; // Your Linear display name.
const DEFAULT_PROJECT = "....-....-....-...-....."; // The default project to create issues in.
const DEFAULT_STATUS = "Backlog"; // The default status to create issues with.
const COLOR_SCHEME = "linear"; // "todoist" | "linear"
let widget = await createWidget();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
switch (WIDGET_SIZE) {
case "large": {
widget.presentLarge();
break;
}
case "small": {
widget.presentSmall();
break;
}
default: {
widget.presentMedium();
break;
}
}
}
Script.complete();
async function createWidget() {
let w = new ListWidget();
let backgroundGradient = new LinearGradient();
backgroundGradient.colors = [new Color("#282828"), new Color("#282828")];
backgroundGradient.locations = [0, 1];
backgroundGradient.startPoint = new Point(0, 1);
backgroundGradient.endPoint = new Point(1, 0);
w.backgroundGradient = backgroundGradient;
const data = await getIssues();
const issueListSize = getissueListSize();
let main = w.addStack();
main.layoutVertically();
main.topAlignContent();
let headerStack = main.addStack();
headerStack.centerAlignContent();
let headingLeft = headerStack.addText(data.organization.name);
headingLeft.leftAlignText();
headingLeft.font = Font.headline();
headingLeft.textColor = new Color("#BD4437");
headingLeft.url = `https://linear.app/${data.organization.urlKey}/view/${VIEW_ID}`;
let headingLeftCounter = data.noData
? headerStack.addText(`No connection`)
: data.usingCachedData
? headerStack.addText(` ${data.count.toString()} (cached)`)
: headerStack.addText(` ${data.count.toString()}`);
headingLeftCounter.leftAlignText();
headingLeftCounter.font = Font.caption1();
headingLeftCounter.textColor = new Color("#BD4437");
headingLeftCounter.url = `https://linear.app/${data.organization.urlKey}/view/${VIEW_ID}`;
if (WIDGET_SIZE !== "small") {
// Jump to "Create issue" in Linear.
headerStack.addSpacer();
let createIssueButton = headerStack.addText("+");
createIssueButton.leftAlignText();
createIssueButton.font = Font.boldSystemFont(18);
createIssueButton.textColor = new Color("#BD4437");
// See more parameters here https://linear.app/docs/create-new-issue-urls?tabs=3a223bdd39bd#generate-pre-filled-link.
createIssueButton.url = `https://linear.app/${data.organization.urlKey}/team/${TEAM_ID}/new?assignee=${DEFAULT_ASSIGNEE}&project=${DEFAULT_PROJECT}&status=${DEFAULT_STATUS}`;
}
main.addSpacer(7);
if (WIDGET_SIZE === "small") {
let unreadTitle = main.addText(String(data.count));
unreadTitle.leftAlignText();
unreadTitle.font = Font.boldSystemFont(45);
unreadTitle.textColor = new Color("#ffffff");
unreadTitle.url = `https://linear.app/${data.organization.urlKey}/view/${VIEW_ID}`;
let unreadSubtitle = main.addText("tasks");
unreadSubtitle.leftAlignText();
unreadSubtitle.font = Font.regularSystemFont(14);
unreadSubtitle.textColor = new Color("#cccccc");
} else {
// Medium and Large widgets.
let listStack = main.addStack();
listStack.layoutVertically();
listStack.spacing = 2;
data.issues.forEach((issue) => {
let issueStack = listStack.addStack();
issueStack.layoutVertically();
issueStack.spacing = 0;
issueStack.url = issue.url;
let titleStack = issueStack.addStack();
titleStack.layoutHorizontally();
titleStack.centerAlignContent();
let bullet;
if (COLOR_SCHEME === "todoist") {
// Circular icon.
bullet = titleStack.addText("⃝ ");
// Todoist color scheme.
if (issue.priority === 1) {
bullet.textColor = new Color("#FE7166");
} else if (issue.priority === 2) {
bullet.textColor = new Color("#FF9A13");
} else if (issue.priority === 3) {
bullet.textColor = new Color("#5298FF");
} else {
bullet.textColor = new Color("#A0A0A0");
}
bullet.leftAlignText();
bullet.font = Font.semiboldSystemFont(20);
bullet.lineLimit = 1;
} else {
// Pull the image from the Scritpable directory in iCloud.
let fm = FileManager.iCloud();
let dir = fm.documentsDirectory();
// Linear color scheme.
if (issue.priority === 1) {
bullet = titleStack.addImage(
Image.fromFile(fm.joinPath(dir, "images/urgent-priority.png"))
);
bullet.imageSize = new Size(15, 15);
} else if (issue.priority === 2) {
bullet = titleStack.addImage(
Image.fromFile(fm.joinPath(dir, "images/high-priority.png"))
);
bullet.imageSize = new Size(15, 15);
} else if (issue.priority === 3) {
bullet = titleStack.addImage(
Image.fromFile(fm.joinPath(dir, "images/medium-priority.png"))
);
bullet.imageSize = new Size(15, 15);
} else if (issue.priority === 4) {
bullet = titleStack.addImage(
Image.fromFile(fm.joinPath(dir, "images/low-priority.png"))
);
bullet.imageSize = new Size(15, 15);
} else {
bullet = titleStack.addImage(
Image.fromFile(fm.joinPath(dir, "images/no-priority.png"))
);
bullet.imageSize = new Size(15, 15);
}
// Add some extra space to the right of the bullet image.
let imageSpacing = titleStack.addText(" ");
imageSpacing.leftAlignText();
imageSpacing.font = Font.body();
}
let title = titleStack.addText(issue.title);
title.leftAlignText();
title.font = Font.lightSystemFont(14);
title.textColor = new Color("#D4D4D4");
title.lineLimit = 1;
let id = titleStack.addText(` (${issue.identifier})`);
id.leftAlignText();
id.font = Font.caption1();
id.textColor = new Color("#cccccc");
let spacingStack = issueStack.addStack();
spacingStack.spacing = 0;
let spacing = spacingStack.addStack();
spacing.size = new Size(5, 5);
});
// Fill in the rest with empty space to pad the widget and avoid the text getting
// centered.
if (data.issues.length < issueListSize) {
for (let i = 0; i < issueListSize - data.issues.length; i++) {
let emptyStack = listStack.addStack();
emptyStack.layoutHorizontally();
emptyStack.centerAlignContent();
let emptyText = emptyStack.addText(" ");
emptyText.font = Font.body();
emptyText.textColor = new Color("#cccccc");
}
}
}
return w;
}
async function getIssues() {
let fm = FileManager.local();
let cachePath = fm.cacheDirectory();
let cache = fm.joinPath(cachePath, "linear-todo-cache.json");
let data;
let usingCachedData = false;
let noData = false;
try {
data = await fetchIssues();
fm.writeString(cache, JSON.stringify(data));
} catch (e) {
if (fm.fileExists(cache)) {
data = JSON.parse(fm.readString(cache));
usingCachedData = true;
} else {
data = {
organization: { name: "", urlKey: "" },
issues: [],
count: 0,
};
noData = true;
}
}
return { ...data, usingCachedData, noData };
}
async function fetchIssues() {
const request = new Request("https://api.linear.app/graphql");
request.method = "POST";
request.headers = {
"Content-Type": "application/json",
Authorization: `${LINEAR_API_KEY}`,
};
// Explore the API at https://studio.apollographql.com/public/Linear-API/variant/current/explorer?explorerURLState=N4IgJg9gxgrgtgUwHYBcQC4QEcYIE4CeABMADpJFGwDOKEcAagJYIDuAFE2OkaSDXTgBaAG4tWQrnwCUJcpUpSKCqHgQBDFAjABBFPJVrNEPHOUKiYJtQAOAG3UEAcusQGL1gLIJ3RAL6%2BJgDm6khMAF6aTBAUZOYKMHh2ANIIBL6UWq7UZhYWSBBgCDlxeXlKZfmuPvEWAbX%2BvvVVbvFF1KpMNijRSL7W1LjU7NQmKDwA2sA2eNGzKAQ8wAUACrMmTAsAYkx4tDwAZup21Ah%2BfgA0JGC4ACKaCEvnV9N4EABWCFDjJEgwdidDrtaFcTEU8DxbsUoMgrEggucALpXA5MOxaCEkPyyUp5ArtXKVCpEoqoJio-AZQwaLS6fQNSiqGkmQmVSzWeyOFytNmKMBUyjNSo3BD3LQCog9FB2Gps9qdbq9CUzOabdIMogqjYLAAy6gARgg7Mr1GpUKySRKhWUZh8vigLWViWVrXlaA9HXjqhLGRA7CYrVTXUL6vUQH4gA.
request.body = JSON.stringify({
query: `query {
customView(id: "${VIEW_ID}") {
organization {
name
urlKey
}
issues(sort: [{project: {nulls: first, order: Descending}}, {priority: {noPriorityFirst: false}}, {dueDate: {}}]) {
nodes {
identifier
dueDate
title
description
creator {
displayName
}
parent {
id
}
priority
priorityLabel
}
}
}
}
`,
variables: {},
});
const response = await request.loadJSON();
const organizationName = response?.data?.customView?.organization?.name;
const organizationUrlKey = response?.data?.customView?.organization?.urlKey;
// We filter out any subtasks by checking if the parent is null.
const formattedIssues = (response?.data?.customView?.issues?.nodes ?? [])
.filter((issue) => issue.parent === null)
.map((issue) => ({
title: issue.title,
description: issue.description,
creator: issue.creator.displayName,
priority: issue.priority,
priorityLabel: issue.priorityLabel,
dueDate: issue.dueDate,
identifier: issue.identifier,
createdAt: issue.createdAt,
url: `https://linear.app/${organizationUrlKey}/issue/${issue.identifier}`,
}));
const issueListSize = getissueListSize();
if (issueListSize) {
return {
organization: {
name: organizationName ?? "Linear",
urlKey: organizationUrlKey,
},
issues: formattedIssues.slice(0, issueListSize),
count: formattedIssues.length,
};
} else {
return {
organization: {
name: organizationName ?? "Linear",
urlKey: organizationUrlKey,
},
issues: formattedIssues,
count: formattedIssues.length,
};
}
}
function getissueListSize() {
switch (WIDGET_SIZE) {
case "large": {
return 12;
}
case "small": {
return false;
}
default: {
return 4;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment