Skip to content

Instantly share code, notes, and snippets.

@dangle
Last active October 16, 2024 22:59
Show Gist options
  • Save dangle/409e2faffcb913767409312463884d76 to your computer and use it in GitHub Desktop.
Save dangle/409e2faffcb913767409312463884d76 to your computer and use it in GitHub Desktop.
Script for use with Obsidian to pull in a snapshot of a day from Todoist

Obsidian Todoist Script

Description

This takes a day in YYYY-MM-DD format and can be called using the CustomJS plugin with either the Dataview plugin or the Templater plugin.

Styling

In order to get nicer styling, copy the todoist.css file to .obsidian/snippets and enable it under Settings → Appearance.

Callouts are generated as todoist-<todoist-color>-<project-name>, all lowercase, and hyphenated. You can customize the colors and icons by adding a .callout[data-callout$="project-name"] section to the CSS.

Dataview

Here is an example using dataviewjs in a Daily Note with the default file name pattern of YYYY-MM-DD.md:

```dataviewjs
const { Todoist } = customJS;
await Todoist.Callout(dv);
```

Templater

Here is the same thing using Templater:

<%*
const { Todoist } = customJS;
tR += await Todoist.getTaskMarkdown(tp.file.title)
%>

Advanced Usage

Using CustomJS, Templater, Dataview, and the Buttons plugins together, put this snippet at the bottom of a Daily Note:

```dataviewjs
const { Todoist } = customJS;
await Todoist.Callout(dv);
```

```button
name Save Tasks as Markdown
type append template
action Burn-in Todoist Tasks
replace [-12, -8]
remove true
```

Put the Templater snippet in the template file Burn-in Todoist Tasks.md.

Combined, this will create a dynamic Todoist view with a button that, when pushed, will vanish and replace the dynamic view with the hardcoded tasks.

/** Inbox Icon **/
.callout[data-callout^="todoist-"][data-callout$="-inbox"] {
--callout-icon: lucide-inbox;
}
/** Berry Red **/
.callout[data-callout^="todoist-berry-red-"] {
--callout-color: 184, 37, 95;
}
.todoist-berry-red {
color: #b8255f;
}
/** Red **/
.callout[data-callout^="todoist-red-"] {
--callout-color: 219, 64, 53;
}
.todoist-red {
color: #db4035;
}
/** Orange **/
.callout[data-callout^="todoist-orange-"] {
--callout-color: 255, 153, 51;
}
.todoist-orange {
color: #ff9933;
}
/** Yellow **/
.callout[data-callout^="todoist-yellow-"] {
--callout-color: 250, 208, 0;
}
.todoist-yellow {
color: #fad000;
}
/** Olive Green **/
.callout[data-callout^="todoist-olive-green-"] {
--callout-color: 175, 184, 59;
}
.todoist-olive-green {
color: #afb83b;
}
/** Lime Green **/
.callout[data-callout^="todoist-lime-green-"] {
--callout-color: 126, 204, 73;
}
.todoist-lime-green {
color: #7ecc49;
}
/** Green **/
.callout[data-callout^="todoist-green-"] {
--callout-color: 41, 148, 56;
}
.todoist-green {
color: #299438;
}
/** Mint Green **/
.callout[data-callout^="todoist-mint-green-"] {
--callout-color: 106, 204, 188;
}
.todoist-mint-green {
color: #6accbc;
}
/** Teal **/
.callout[data-callout^="todoist-teal-"] {
--callout-color: 21, 143, 173;
}
.todoist-teal {
color: #158fad;
}
/** Sky Blue **/
.callout[data-callout^="todoist-sky-blue-"] {
--callout-color: 20, 170, 245;
}
.todoist-sky-blue {
color: #14aaf5;
}
/** Light Blue **/
.callout[data-callout^="todoist-light-blue-"] {
--callout-color: 150, 195, 235;
}
.todoist-light-blue {
color: #96c3eb;
}
/** Blue **/
.callout[data-callout^="todoist-blue-"] {
--callout-color: 64, 115, 255;
}
.todoist-blue {
color: #4073ff;
}
/** Grape **/
.callout[data-callout^="todoist-grape-"] {
--callout-color: 136, 77, 255;
}
.todoist-grape {
color: #884dff;
}
/** Violet **/
.callout[data-callout^="todoist-violet-"] {
--callout-color: 175, 56, 235;
}
.todoist-violet {
color: #af38eb;
}
/** Lavender **/
.callout[data-callout^="todoist-lavender-"] {
--callout-color: 235, 150, 235;
}
.todoist-lavender {
color: #eb96eb;
}
/** Magenta **/
.callout[data-callout^="todoist-magenta-"] {
--callout-color: 224, 81, 148;
}
.todoist-magenta {
color: #e05194;
}
/** Salmon **/
.callout[data-callout^="todoist-salmon-"] {
--callout-color: 255, 141, 133;
}
.todoist-salmon {
color: #ff8d85;
}
/** Charcoal **/
.callout[data-callout^="todoist-charcoal-"] {
--callout-color: 128, 128, 128;
}
.todoist-charcoal {
color: #808080;
}
/** Grey **/
.callout[data-callout^="todoist-grey-"] {
--callout-color: 184, 184, 184;
}
.todoist-grey {
color: #b8b8b8;
}
/** Taupe **/
.callout[data-callout^="todoist-taupe-"] {
--callout-color: 204, 172, 147;
}
.todoist-taupe {
color: #ccac93;
}
/** Extra Info **/
.callout[data-callout^="todoist-"] li ul li {
font-size: smaller;
color: #999;
display: inline;
margin: 0 0.25em;
}
.callout[data-callout^="todoist-"] li ul li:first-child {
margin-left: -2em;
}
.callout[data-callout^="todoist-"] li ul li .list-bullet {
display: none;
}
.todoist-postponed {
font-weight: bold;
font-style: italic;
}
.todoist-postponed::before {
content: "« ";
}
.todoist-postponed::after {
content: " »";
}
/** Task Date **/
.todoist-task-date {
color: #299438;
}
.todoist-task-date::before {
margin-right: .2em;
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='.7em' height='.7em' viewBox='0 0 24 24' fill='none' stroke='rgb(41, 148, 56)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='lucide lucide-calendar'%3E%3Crect width='18' height='18' x='3' y='4' rx='2' ry='2'/%3E%3Cline x1='16' x2='16' y1='2' y2='6'/%3E%3Cline x1='8' x2='8' y1='2' y2='6'/%3E%3Cline x1='3' x2='21' y1='10' y2='10'/%3E%3C/svg%3E");
}
.todoist-task-recurring::after {
margin-left: .2em;
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='.7em' height='.7em' viewBox='0 0 24 24' fill='none' stroke='rgb(41, 148, 56)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='lucide lucide-repeat-2'%3E%3Cpath d='m2 9 3-3 3 3'/%3E%3Cpath d='M13 18H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='m22 15-3 3-3-3'/%3E%3Cpath d='M11 6h6a2 2 0 0 1 2 2v10'/%3E%3C/svg%3E");
}
class Todoist {
static API_TOKEN() {
return '<YOUR API TOKEN HERE>';
}
kebab(string) {
return string
.replace(/([a-z])([A-Z]+)/g, (_, s1, s2) => `${s1} ${s2}`)
.replace(
/([A-Z])([A-Z]+)([^a-zA-Z0-9]*)$/,
(_, s1, s2, s3) => `${s1} ${s2.toLowerCase()} ${s3}`,
)
.replace(
/([A-Z]+)([A-Z][a-z])/g,
(_, s1, s2) => `${s1.toLowerCase()} ${s2}`,
)
.replace(/\W+/g, ' ')
.replace(/_/g, '-')
.split(/ |\B(?=[A-Z])/)
.map((word) => word.toLowerCase())
.join('-');
}
simpleLocalDate(date) {
const parts = date.slice(0, 10).split('-');
const localDate = new Date(parts[0], parts[1] - 1, parts[2]);
localDate.setHours(0, 0, 0, 0);
return localDate;
}
async getProjects() {
const url = 'https://api.todoist.com/rest/v2/projects';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
return data.reduce((acc, project) => {
acc[project.id] = project;
acc[project.id].tasks = {};
return acc;
}, {});
}
async getLabels() {
const url = 'https://api.todoist.com/rest/v2/labels';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
return data.reduce((acc, label) => {
acc[label.name] = label;
return acc;
}, {});
}
async getPreviousDueDatesForTask(taskID) {
const url =
'https://api.todoist.com/sync/v9/activity/get?' +
new URLSearchParams({
object_id: taskID,
object_type: 'item',
event_type: 'updated',
limit: 100,
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
return data.events.reduce((acc, event) => {
if (event.extra_data.last_due_date !== undefined) {
const date = new Date(event.extra_data.last_due_date);
date.setHours(0, 0, 0, 0);
acc.push(date);
}
return acc;
}, []);
}
async getItemInfo(taskID) {
const url =
'https://api.todoist.com/sync/v9/items/get?' +
new URLSearchParams({
item_id: taskID,
all_data: false,
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
return data.item;
}
async getActiveTasks(date) {
if (date === undefined) {
date = new Date().toJSON().slice(0, 10);
}
const url =
'https://api.todoist.com/rest/v2/tasks?' +
new URLSearchParams({
filter: `(created before: ${date} | created: ${date}) & !no date`,
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
const givenDate = this.simpleLocalDate(date);
const givenDateString = givenDate.toUTCString();
return await data.reduce(async (acc, task) => {
const dueDates = await this.getPreviousDueDatesForTask(task.id);
const dueDate = this.simpleLocalDate(task.due.date);
dueDates.unshift(dueDate);
const index = dueDates.findIndex(
(date) => date.toUTCString() === givenDateString,
);
if (index > -1) {
task.postponed = dueDates.length - index - 1;
(await acc).push(task);
}
return acc;
}, []);
}
async getCompletedTasks(date) {
if (date === undefined) {
date = new Date().toJSON().slice(0, 10);
}
const givenDate = this.simpleLocalDate(date);
const until = this.simpleLocalDate(date);
until.setHours(23, 59);
const url =
'https://api.todoist.com/sync/v9/completed/get_all?' +
new URLSearchParams({
since: givenDate.toJSON(),
until: until.toJSON(),
limit: 200,
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${Todoist.API_TOKEN()}`,
},
});
const data = await response.json();
return await Promise.all(
data.items.map(async (completedTask) => {
return await this.getItemInfo(completedTask.task_id);
}),
);
}
async getTasksByProject(date) {
if (date === undefined) {
date = new Date().toJSON().slice(0, 10);
}
const active_tasks = await this.getActiveTasks(date);
const completed_tasks = await this.getCompletedTasks(date);
const projects = await this.getProjects();
active_tasks.forEach((task) => {
projects[task.project_id].tasks[task.id] = task;
});
completed_tasks.forEach((task) => {
projects[task.project_id].tasks[task.id] = task;
});
return Object.values(projects).sort((a, b) => (a.name > b.name ? 1 : -1));
}
async getTaskMarkdown(date) {
if (date === undefined) {
date = new Date().toJSON().slice(0, 10);
}
const labels = await this.getLabels();
const projects = await this.getTasksByProject(date);
return projects.reduce((acc, project) => {
const tasks = Object.values(project.tasks).sort((a, b) => {
if (a.content > b.content) {
return 1;
}
if (a.content < b.content) {
return -1;
}
if (
a.due !== undefined &&
a.due !== null &&
b.due !== undefined &&
b.due !== null
) {
return a.due.date > b.due.date ? 1 : -1;
}
return 0;
});
if (tasks.length === 0) {
return acc;
}
acc += `> [!todoist-${this.kebab(project.color)}-${this.kebab(
project.name,
)}]+ ${project.name}\n`;
tasks.forEach((task) => {
if (task.is_completed === false) {
acc += `> - [ ]`;
} else {
acc += `> - [x]`;
}
acc += ` ${task.content}\n`;
let extraParts = [];
if (task.due !== undefined && task.due !== null) {
let due = '';
if (task.due.datetime !== undefined) {
const dueDate = new Date(task.due.datetime);
due = dueDate.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
} else if (task.due.date !== undefined && task.due.date.length > 10) {
const dueDate = new Date(task.due.date);
due = dueDate.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
if (due.length > 0 || task.due.is_recurring === true) {
extraParts.push(
`<span class="todoist-task-date${
task.due.is_recurring === true ? ' todoist-task-recurring' : ''
}">${due}</span>`,
);
}
}
if (task.labels !== undefined && task.labels.length > 0) {
extraParts = extraParts.concat(
task.labels.map(
(label) =>
`<span class="todoist-${this.kebab(
labels[label].color,
)}">${label}</span>`,
),
);
}
if (task.postponed !== undefined && task.postponed > 0) {
extraParts.push(
`<span class="todoist-postponed">${task.postponed}</span>`,
);
}
if (extraParts.length > 0) {
acc += `${extraParts
.map((part) => {
return `> - ${part}\n`;
})
.join('')}`;
}
});
acc += '\n';
return acc;
}, '');
}
async Callout(dv) {
const filename = dv.current().file.name;
const markdown = await this.getTaskMarkdown(filename);
dv.span(markdown);
}
}
@dangle
Copy link
Author

dangle commented Dec 12, 2023

Sure.

@dave58917
Copy link

How do I contact you directly? @dangle

@dangle
Copy link
Author

dangle commented Dec 18, 2023

@konradjkras
Copy link

Hello:)
Wonderful script:) Thanks a lot of it. It got me started experimenting with obsidian integrations.
As my setup is fresh and evolving I have got into a tiny issue that most seasoned Todoist users will likely not encounter:

Problem:

I have changed a name of a label in Todoist. Unfortunately tasks API returns labels by name:( It caused your script broke at line 264 with non existing color property.

Solution/workaround

Javascript is not my expertise area so there are likely more elegant ways to fix that(at least avoiding "magic constants", or choosing nicer color:)) Maybe there is something neutral your kebab function can accept without causing more stomach problems.
A workaround I am personaly ok with is to replace line 264 with:

(labels[label] == null)? "charcoal" : labels[label].color,

I thought I would share in case anybody else encounters that problem.
Thanks for a great script! :)

@rasmusagdestein
Copy link

I'm trying to use this. I use the first above example:

const { Todoist } = customJS;
await Todoist.Callout(dv);

but i get the following error:
image

Do you know what is the problem?

@dangle
Copy link
Author

dangle commented May 14, 2024

It looks like you got an error from the Todoist API.

I'd have to see more of the error to know why for sure. Are your pages in the format YYYY-MM-DD.md? That's necessary for using the Todoist.Callout(dv); method.

@fadialzammar
Copy link

If anyone is running into @dave58917's issues, for me the issue was that I needed to make sure the script was loaded in CustomJS's settings, not Templater's settings.

@dave58917
Copy link

If anyone is running into @dave58917's issues, for me the issue was that I needed to make sure the script was loaded in CustomJS's settings, not Templater's settings.

@fadialzammar I'm not sure I follow, can you elaborate?

@weemoedig
Copy link

Loving the concept!

Unfortunately, I cannot get the render as smooth and slick as visualized by you. I do have the .css snippet enabled.

Capture

Looking forward to your reply!

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