|
// 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; |
|
} |
|
} |
|
} |