Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 153 additions & 74 deletions docs/public/scripts/TodoistScript.js
Original file line number Diff line number Diff line change
@@ -1,99 +1,178 @@
module.exports = {SelectFromAllTasks, GetAllTasksFromProject, GetAllTasksFromSection};

const getTodoistPluginApi = (app) => app.plugins.plugins["todoist-sync-plugin"].api;

/* API */
async function SelectFromAllTasks(params) {
const tasks = await getAllTasks(params);
// Imports Todoist tasks into a note as Tasks-plugin checklist items.
// Talks to the Todoist API v1 directly: https://developer.todoist.com/api/v1/
// Docs: https://quickadd.obsidian.guide/docs/Examples/Capture_FetchTasksFromTodoist
const API_TOKEN = "Todoist API token";
const COMPLETE_TASKS = "Complete imported tasks in Todoist";
const TODOIST_API = "https://api.todoist.com/api/v1";

module.exports = {
entry: async (params, settings) => {
const exports = {SelectFromAllTasks, GetAllTasksFromProject, GetAllTasksFromSection};
const names = Object.keys(exports);
const selected = await params.quickAddApi.suggester(names, names, "Import Todoist tasks");
if (!selected) return "";
return exports[selected](params, settings);
},
settings: {
name: "Todoist",
author: "Christian B. B. Houmann",
options: {
[API_TOKEN]: {
type: "secret",
id: "todoist-api-token",
placeholder: "Paste API token",
description: "From Todoist's Developer settings.",
},
[COMPLETE_TASKS]: {
type: "checkbox",
defaultValue: true,
description: "Mark each imported task as complete in Todoist, so it is only imported once.",
},
},
},
SelectFromAllTasks,
GetAllTasksFromProject,
GetAllTasksFromSection,
};

/* Exports */
async function SelectFromAllTasks(params, settings) {
const todoist = createTodoistClient(params, settings);
const tasks = await todoist.getAll("/tasks");
if (tasks.length === 0) {
new Notice("No tasks.");
return;
new params.obsidian.Notice("No tasks in Todoist.");
return "";
}
const selectedTasks = await selectTasks(params, tasks);

await closeSelectedTasks(params.app, selectedTasks);
return formatTasksToTasksPluginTask(selectedTasks);
const labels = tasks.map((task, index) => `${index + 1}. ${task.content}`);
const selectedLabels = await params.quickAddApi.checkboxPrompt(labels);
const selectedTasks = tasks.filter((_, index) => selectedLabels.includes(labels[index]));

return importTasks(params, settings, todoist, selectedTasks, "your selection");
}

async function GetAllTasksFromProject(params) {
const [allTasks, projects] = await Promise.all([getAllTasks(params), getProjects(params.app)]);
const targetProject = await params.quickAddApi.suggester(project => {
project.tasks = allTasks.filter(task => task.projectID === project.id);
async function GetAllTasksFromProject(params, settings) {
const todoist = createTodoistClient(params, settings);
const [projects, tasks] = await Promise.all([todoist.getAll("/projects"), todoist.getAll("/tasks")]);
const tasksIn = (project) => tasks.filter(task => task.project_id === project.id);

return `${project.name} (${project.tasks.length})`;
}, projects);
if (!targetProject) return;
const project = await params.quickAddApi.suggester(
projects.map(project => `${project.name} (${tasksIn(project).length})`),
projects,
"Import all tasks from project",
);
if (!project) return "";

if (targetProject.tasks.length === 0) {
new Notice(`No tasks in '${targetProject.name}'.`);
return;
} else {
new Notice(`Added ${targetProject.tasks.length} tasks from '${targetProject.name}'.`)
}
return importTasks(params, settings, todoist, tasksIn(project), `'${project.name}'`);
}

await closeSelectedTasks(params.app, targetProject.tasks);
return formatTasksToTasksPluginTask(targetProject.tasks);
async function GetAllTasksFromSection(params, settings) {
const todoist = createTodoistClient(params, settings);
const [projects, sections, tasks] = await Promise.all([
todoist.getAll("/projects"),
todoist.getAll("/sections"),
todoist.getAll("/tasks"),
]);
const tasksIn = (section) => tasks.filter(task => task.section_id === section.id);
const projectName = (section) => projects.find(project => project.id === section.project_id)?.name ?? "?";

const section = await params.quickAddApi.suggester(
sections.map(section => `${projectName(section)} > ${section.name} (${tasksIn(section).length})`),
sections,
"Import all tasks from section",
);
if (!section) return "";

return importTasks(params, settings, todoist, tasksIn(section), `'${section.name}'`);
}

async function GetAllTasksFromSection(params) {
const [projects, sections, allTasks] = await Promise.all([getProjects(params.app), getSections(params.app), getAllTasks(params)]);
/* Helpers */
async function importTasks(params, settings, todoist, tasks, sourceName) {
if (tasks.length === 0) {
new params.obsidian.Notice(`No tasks in ${sourceName}.`);
return "";
}

const targetSection = await params.quickAddApi.suggester(section => {
const sectionProject = projects.find(project => project.id === section["project_id"]);
section.tasks = allTasks.filter(task => task.sectionID === section.id);
return `${sectionProject.name} > ${section.name} (${section.tasks.length})`;
}, sections);
// Format before completing: a recurring task gets a new due date when completed.
const output = formatTasksToTasksPluginTask(tasks);

if (targetSection.tasks.length === 0) {
new Notice(`No tasks in '${targetSection.name}'.`);
return;
} else {
new Notice(`Added ${targetSection.tasks.length} tasks from '${targetSection.name}'.`)
// IMPORTANT: this completes the imported tasks in Todoist.
// Untick "Complete imported tasks in Todoist" in the script settings to keep them open.
if (settings[COMPLETE_TASKS]) {
const failed = await closeSelectedTasks(todoist, tasks);
if (failed.length > 0) {
new params.obsidian.Notice(`Could not complete ${failed.length} of ${tasks.length} imported tasks in Todoist; they are still open there.`, 10000);
}
}

await closeSelectedTasks(targetSection.tasks);
return formatTasksToTasksPluginTask(targetSection.tasks);
new params.obsidian.Notice(`Added ${tasks.length} ${tasks.length === 1 ? "task" : "tasks"} from ${sourceName}.`);
return output;
}

/* Helpers */
async function getAllTasks(params) {
const api = getTodoistPluginApi(params.app);
const {ok: tasks} = await api.getTasks();
return tasks;
// Returns the tasks that could not be completed. The note still gets every
// imported task, so a failed close never loses one that was already closed.
async function closeSelectedTasks(todoist, tasks) {
const failed = [];
for (const task of tasks) {
try {
await todoist.request("POST", `/tasks/${task.id}/close`);
} catch (error) {
console.error(`Todoist: could not complete task ${task.id}`, error);
failed.push(task);
}
}
return failed;
}

async function selectTasks(params, tasks) {
const selectedTaskNames = await params.quickAddApi.checkboxPrompt(tasks.map(task => task.content));
return tasks.filter(task => selectedTaskNames.some(t => t.contains(task.content)));
function formatTasksToTasksPluginTask(tasks) {
return tasks.map(task => {
const dueDate = formatDueDate(task.due);
return dueDate ? `- [ ] ${task.content} 📅 ${dueDate}` : `- [ ] ${task.content}`;
}).join("\n") + "\n";
}

async function closeSelectedTasks(app, tasks) {
const api = getTodoistPluginApi(app);
tasks.forEach(async task => await api.closeTask(task.id));
// `due.date` is "YYYY-MM-DD", a floating "YYYY-MM-DDTHH:mm:ss", or a UTC
// "YYYY-MM-DDTHH:mm:ssZ" for tasks with a fixed time zone.
function formatDueDate(due) {
if (!due?.date) return null;
if (due.date.endsWith("Z")) return window.moment(due.date).format("YYYY-MM-DD");
return due.date.slice(0, 10);
}

function formatTasksToTasksPluginTask(tasks) {
return tasks.map(task =>
task.rawDatetime ?
task = `- [ ] ${task.content} 📅 ${task.rawDatetime.format("YYYY-MM-DD")}` :
task = `- [ ] ${task.content}`
).join("\n") + "\n";
}
function createTodoistClient(params, settings) {
const token = settings[API_TOKEN];
if (!token) {
throw new Error("Add your Todoist API token in the Todoist script's settings (the gear next to the script in your macro).");
}

async function getTasksGroupedByProject(app) {
const api = getTodoistPluginApi(app);
const {ok: projects} = await api.getTasksGroupedByProject();
return projects;
}
async function request(method, path) {
const response = await params.obsidian.requestUrl({
url: `${TODOIST_API}${path}`,
method,
headers: {Authorization: `Bearer ${token}`},
throw: false,
});
if (response.status === 401 || response.status === 403) {
throw new Error(`Todoist rejected the API token (HTTP ${response.status}). Check the token in the Todoist script's settings.`);
}
if (response.status >= 400) {
throw new Error(`Todoist request ${method} ${path.split("?")[0]} failed with HTTP ${response.status}.`);
}
return response.text ? response.json : null;
}

async function getProjects(app) {
const api = getTodoistPluginApi(app);
const {ok: projects} = await api.getProjects();
return projects;
}
// List endpoints are paginated: follow `next_cursor` until it is null.
async function getAll(path) {
const results = [];
let cursor = null;
do {
const query = `?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const page = await request("GET", `${path}${query}`);
results.push(...page.results);
cursor = page.next_cursor;
} while (cursor);
return results;
}

async function getSections(app) {
const api = getTodoistPluginApi(app);
const {ok: sections} = await api.getSections();
return sections;
}
return {request, getAll};
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ You end up with one QuickAdd command that pulls tasks from your Todoist account

## Prerequisites

- The [Todoist](https://github.com/jamiebrynes7/obsidian-todoist-plugin) plugin for Obsidian, set up with your API key. There is a link to grab the key in the plugin's settings.
- A [macro](/docs/Choices/MacroChoice/) that runs the <a href="/scripts/TodoistScript.js" download>Todoist Script</a> (set up below).
- A Todoist API token. In Todoist, open **Settings → Integrations → Developer** and copy the **API token**. The script talks to the [Todoist API](https://developer.todoist.com/api/v1/) directly, so you don't need the Todoist plugin for Obsidian.
- The <a href="/scripts/TodoistScript.js" download>Todoist Script</a>, saved in your vault as a `.js` file, for example `scripts/todoistTaskSync.js`. Don't save it inside the `.obsidian` folder - QuickAdd ignores scripts there.
- A [macro](/docs/Choices/MacroChoice/) that runs the script, and a [Capture choice](/docs/Choices/CaptureChoice/) that writes its output to a note (set up below).

## How it works

Expand All @@ -19,41 +20,44 @@ The Todoist Script has three exports, `SelectFromAllTasks`, `GetAllTasksFromProj
- `GetAllTasksFromProject` will prompt you for a project and get all tasks from that project, and
- `GetAllTasksFromSection` will prompt you for a section and get all tasks from that section.

Personally, I just let QuickAdd ask me which one to execute.
If you run the macro as-is, the script asks you which one to run. To always run one of them, reference it in the Capture format with `::`, for example `{{MACRO:Todoist::GetAllTasksFromProject}}`.

However, when you are entering the user script in the macro, you can add `::GetAllTasksFromProject` (or, `::` followed by any of the other exports) to directly call one of the exported functions.
:::caution[Imported tasks are completed in Todoist]
By default, the script completes every task it imports, so the same task isn't imported twice. Recurring tasks move to their next occurrence instead. To keep the tasks open in Todoist, untick **Complete imported tasks in Todoist** in the script's settings.
:::

![Get all tasks from project](../Images/Todoist-GetAllTasksFromProject.png)
## Setup

**IMPORTANT:** If you do _NOT_ want this script to complete tasks in Todoist that you put into your vault, remove the function call to `closeSelectedTasks`.
1. Save the <a href="/scripts/TodoistScript.js" download>Todoist Script</a> to your vault, for example as `scripts/todoistTaskSync.js`.
2. In **Settings → QuickAdd**, add a [Macro choice](/docs/Choices/MacroChoice/) named `Todoist`, and add the script to its command list. Add the script by its file name only - don't add `::GetAllTasksFromProject` there, because the script's settings are only available when the command points at the whole script.
3. Click the gear (⚙️) next to the script command, and paste your Todoist API token into **Todoist API token**. QuickAdd keeps it in Obsidian's secret storage, not in `data.json`. Leave **Complete imported tasks in Todoist** ticked, or untick it to leave tasks open in Todoist.

Now, you will need a [Capture choice](/docs/Choices/CaptureChoice/) with the following settings.
![Todoist script settings](../Images/Todoist-ScriptSettings.png)

- _Capture to:_ the path to the file where you want to store the tasks.
- _Capture format:_ Enabled - and in the format, write `{{MACRO:<MACRONAME>}}` where `MACRONAME` is the name of the macro that you made earlier.
4. Add a [Capture choice](/docs/Choices/CaptureChoice/) with these settings:
- _Capture to:_ the path to the file where you want to store the tasks.
- _Capture format:_ Enabled - and in the format, write `{{MACRO:Todoist}}` to be asked which export to run, or `{{MACRO:Todoist::GetAllTasksFromProject}}` (or any of the other exports) to run that one directly.

You can make one Capture choice per export, so each has its own command.

## What you get

The tasks are written in this format:
`- [ ] <Task Content> 📆 <YYYY-MM-DD>`
`- [ ] <Task Content> 📅 <YYYY-MM-DD>`

Which equals: `- [ ] Buy groceries 📆 2021-06-27`
Which equals: `- [ ] Buy groceries 📅 2021-06-27`

This task will be recognized by the Tasks plugin for Obsidian, as well.
If there isn't a date set for the task, they'll simply be entered as `- [ ] Buy groceries`.

### Steps

_NOTE:_ If you simply follow the process below, you will be asked which export to execute each time.
That is fine - if you want to be asked - but you can also make separate [Capture choices](/docs/Choices/CaptureChoice/) for each exported function, meaning, it'll execute that function without asking you which one to execute.
Just set up the macro as shown in the image above.
## Troubleshooting

1. Set up the Todoist plugin - grab the API key from your Todoist account. There's a link in the plugin's settings.
2. Download the Todoist Script (linked above) and add it to your vault as a javascript file. I'd encourage you to call it something like todoistTaskSync.js to be explicit.
3. Follow along with what I do in the gif below

![GKkCNWZHLv](https://user-images.githubusercontent.com/29108628/123500983-26ad2880-d642-11eb-9e45-b537271312d1.gif)
- **"Add your Todoist API token in the Todoist script's settings"**: open the macro, click the gear next to the script command, and paste the token. If the script command's name ends in `::SomeExport`, remove that part so the gear shows the settings.
- **"Todoist rejected the API token (HTTP 401)"**: the token is wrong or was reset. Copy it again from Todoist's Developer settings.
- **"Secret setting ... is unavailable. Re-enter it on this device."**: secrets are stored per device. Paste the token again on this device.

### Installation video

This older video shows the general flow of adding a user script to a macro and a Capture choice. It predates the API token setting: instead of setting up the Todoist plugin, paste your token in the script's settings as described above.

https://user-images.githubusercontent.com/29108628/123511101-bde4a100-d67f-11eb-90c1-5bd146c5d0f2.mp4
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading