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);
}
}
@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