Skip to content

Instantly share code, notes, and snippets.

@dangle
Last active February 7, 2024 20:11
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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);
}
}
@BrunoGomesCoelho
Copy link

This is so useful, thanks so much for sharing!

@dave58917
Copy link

dave58917 commented Nov 27, 2023

Hi! Where do I put the todoist.js file? This is EXACTLY what I'm looking for. A step-by-step for dummies like me would be appreciated!

@dangle
Copy link
Author

dangle commented Nov 27, 2023

Hi! Where do I put the todoist.js file? This is EXACTLY what I'm looking for. A step-by-step for dummies like me would be appreciated!

You need to create a folder in your vault and point CustomJS at it. Then put the script in that folder. If you have a Templater scripts folder, this should not be the same folder as CustomJS requires a different format than Templater.

@dave58917
Copy link

Hi @dangle ,

So I have the Advanced Usage snippet in my Daily Notes template.

I have the Templater snippet saved in a folder called scripts, and the Templater settings pointing to this folder.

I have todoist.js in a folder called CustomJS, and the Individual File and Folder set up in CustomJS Settings.

My todoist API is configured correctly.

This is as far as I've gotten, what am I missing?
image

Thank you!

@dangle
Copy link
Author

dangle commented Nov 30, 2023

Hi @dangle ,

So I have the Advanced Usage snippet in my Daily Notes template.

I have the Templater snippet saved in a folder called scripts, and the Templater settings pointing to this folder.

I have todoist.js in a folder called CustomJS, and the Individual File and Folder set up in CustomJS Settings.

My todoist API is configured correctly.

This is as far as I've gotten, what am I missing? image

Thank you!

That looks like the Todoist variable isn't being read from customJS properly. I don't use the Individual files option in the settings; I only set the Folder value. Try unsetting the Individual files, maybe?

You shouldn't need the Templater bit as a script. That's meant to go in your template if you're only using Templater.

@dave58917
Copy link

Hi @dangle ,
So I have the Advanced Usage snippet in my Daily Notes template.
I have the Templater snippet saved in a folder called scripts, and the Templater settings pointing to this folder.
I have todoist.js in a folder called CustomJS, and the Individual File and Folder set up in CustomJS Settings.
My todoist API is configured correctly.
This is as far as I've gotten, what am I missing? image
Thank you!

That looks like the Todoist variable isn't being read from customJS properly. I don't use the Individual files option in the settings; I only set the Folder value. Try unsetting the Individual files, maybe?

You shouldn't need the Templater bit as a script. That's meant to go in your template if you're only using Templater.

Tried this to no avail unfortunately.
image

@dangle
Copy link
Author

dangle commented Dec 5, 2023

If you put the

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

block in your daily note and use the Templater: Replace templates in active file option, do you get an error?

@dave58917
Copy link

Hi @dangle - when I run that command, I get this error:
image

When I open the console, I see this:
image

I hate to blow up your gist - can I contact you directly with all my config to verify its correctness? I'm happy to buy you a cup of coffee.

@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

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