Skip to content

Todoist sample

This Pack provides an integration with the task tracking app Todoist. It uses a variety of building blocks to allow users to work with their projects and tasks, including:

  • Formulas that provide rich data about an item given its URL.
  • Column formats that automatically apply those formulas to matching URLs.
  • Action formulas that create and update items, for use in button and automations.
  • Sync tables for pulling in all of the user's items.

The Pack uses OAuth2 to connect to a user's Todoist account, which you can create for free.

import * as coda from "@codahq/packs-sdk";

// #region Constants

const ProjectUrlPatterns: RegExp[] = [
  new RegExp("^https://todoist.com/app/project/([0-9]+)$"),
  new RegExp("^https://todoist.com/showProject\\?id=([0-9]+)"),
];

const TaskUrlPatterns: RegExp[] = [
  new RegExp("^https://todoist.com/app/task/([0-9]+)$"),
  new RegExp("^https://todoist.com/app/project/[0-9]+/task/([0-9]+)$"),
  new RegExp("^https://todoist.com/showTask\\?id=([0-9]+)"),
];

// #endregion


// #region Pack setup

export const pack = coda.newPack();

pack.addNetworkDomain("todoist.com");

pack.setUserAuthentication({
  type: coda.AuthenticationType.OAuth2,
  // OAuth2 URLs and scopes are found in the the Todoist OAuth guide:
  // https://developer.todoist.com/guides/#oauth
  authorizationUrl: "https://todoist.com/oauth/authorize",
  tokenUrl: "https://todoist.com/oauth/access_token",
  scopes: ["data:read_write"],
  scopeDelimiter: ",",

  // Determines the display name of the connected account.
  getConnectionName: async function (context) {
    let url = coda.withQueryParams("https://api.todoist.com/sync/v9/sync", {
      resource_types: JSON.stringify(["user"]),
    });
    let response = await context.fetcher.fetch({
      method: "GET",
      url: url,
    });
    return response.body.user?.full_name;
  },
});

// #endregion


// #region Schemas

const DueSchema = coda.makeObjectSchema({
  properties: {
    date: {
      description: "The date the task is due.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.Date,
    },
    time: {
      description: "The specific moment the task is due.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.DateTime,
      fromKey: "datetime",
    },
    display: {
      description: "The display value for the due date.",
      type: coda.ValueType.String,
      fromKey: "string",
    },
  },
  displayProperty: "display",
});

const ProjectSchema = coda.makeObjectSchema({
  properties: {
    name: {
      description: "The name of the project.",
      type: coda.ValueType.String,
      mutable: true,
      required: true,
    },
    url: {
      description: "A link to the project in the Todoist app.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.Url,
    },
    shared: {
      description: "Is the project is shared.",
      type: coda.ValueType.Boolean,
      fromKey: "is_shared",
    },
    favorite: {
      description: "Is the project a favorite.",
      type: coda.ValueType.Boolean,
      mutable: true,
      fromKey: "is_favorite",
    },
    id: {
      description: "The ID of the project.",
      type: coda.ValueType.String,
      required: true,
    },
    parentProjectId: {
      description: "For sub-projects, the ID of the parent project.",
      type: coda.ValueType.String,
      fromKey: "parent_id",
    },
  },
  displayProperty: "name",
  // Sync table metadata.
  idProperty: "id",
  featuredProperties: ["url", "favorite"],
  // Card metadata.
  linkProperty: "url",
  subtitleProperties: ["shared", "favorite"],
});

// Create a reference schema for projects, to use for relation columns.
const ProjectReferenceSchema =
  coda.makeReferenceSchemaFromObjectSchema(ProjectSchema, "Project");

// Using the reference schema, add a property for the parent project.
(ProjectSchema.properties as coda.ObjectSchemaProperties)
  .parentProject = ProjectReferenceSchema;

const TaskSchema = coda.makeObjectSchema({
  properties: {
    name: {
      description: "The name of the task.",
      type: coda.ValueType.String,
      fromKey: "content",
      required: true,
      mutable: true,
    },
    description: {
      description: "A detailed description of the task.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.Markdown,
      mutable: true,
    },
    url: {
      description: "A link to the task in the Todoist app.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.Url,
    },
    completed: {
      description: "If the task has been completed.",
      type: coda.ValueType.Boolean,
      fromKey: "is_completed",
      mutable: true,
    },
    order: {
      description: "The position of the task in the project or parent task.",
      type: coda.ValueType.Number,
      mutable: true,
    },
    priority: {
      description: "The priority of the task.",
      type: coda.ValueType.String,
      codaType: coda.ValueHintType.SelectList,
      options: ["P1", "P2", "P3", "P4"],
      mutable: true,
    },
    due: {
      description: "When the task is due.",
      ...DueSchema,
    },
    id: {
      description: "The ID of the task.",
      type: coda.ValueType.String,
      required: true,
    },
    projectId: {
      description: "The ID of the project that the task belongs to.",
      type: coda.ValueType.String,
      fromKey: "project_id",
    },
    parentTaskId: {
      description: "For sub-tasks, the ID of the parent task it belongs to.",
      type: coda.ValueType.String,
      fromKey: "parent_id",
    },
    // A reference to the project (for sync tables only).
    project: {
      ...ProjectReferenceSchema,
      mutable: true,
    },
  },
  displayProperty: "name",
  // Sync table metadata.
  idProperty: "id",
  featuredProperties: ["project", "url", "completed"],
  // Card metadata.
  linkProperty: "url",
  snippetProperty: "description",
  subtitleProperties: [
    "priority",
    "completed",
    { label: "Due", property: "due.display" },
  ],
});

// Create a reference schema for tasks, to use for relation columns.
const TaskReferenceSchema =
  coda.makeReferenceSchemaFromObjectSchema(TaskSchema, "Task");

// Using the reference schema, add a property for the parent task.
(TaskSchema.properties as coda.ObjectSchemaProperties)
  .parentTask = TaskReferenceSchema;

// Format a project from the API and return an object matching the schema.
function formatProjectForSchema(project: any, withReferences = false) {
  let result: any = {
    ...project,
  };
  if (withReferences && project.parent_id) {
    result.parentProject = {
      id: project.parent_id,
      name: "Not found", // If sync'ed, the real name will be shown instead.
    };
  }
  return result;
}

// Format a task from the API and return an object matching the Task schema.
function formatTaskForSchema(task: any, withReferences = false) {
  let result: any = {
    ...task,
    // Convert the priority to a string like "P1".
    priority: "P" + (5 - task.priority),
  };
  if (withReferences) {
    // Add a reference to the corresponding row in the Projects sync table.
    result.project = {
      id: task.project_id,
      name: "Not found", // If sync'ed, the real name will be shown instead.
    };
    if (task.parent_id) {
      // Add a reference to the corresponding row in the Tasks sync table.
      result.parentTask = {
        id: task.parent_id,
        name: "Not found", // If sync'ed, the real name will be shown instead.
      };
    }
  }
  return result;
}

// Format a task from a sync table and return an object matching the API.
function formatTaskForAPI(task: any) {
  let result: any = {
    ...task,
  };
  if (result.priority) {
    // Convert the priority back to a number.
    result.priority = 5 - Number(result.priority.substring(1));
  }
  return result;
}

// #endregion


// #region Formulas

pack.addFormula({
  name: "Project",
  description: "Gets a Todoist project by URL",
  parameters: [
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "url",
      description: "The URL of the project",
    }),
  ],
  resultType: coda.ValueType.Object,
  schema: ProjectSchema,
  execute: async function ([url], context) {
    let projectId = extractProjectId(url);
    let response = await context.fetcher.fetch({
      url: "https://api.todoist.com/rest/v2/projects/" + projectId,
      method: "GET",
    });
    return formatProjectForSchema(response.body);
  },
});

pack.addFormula({
  name: "Task",
  description: "Gets a Todoist task by URL",
  parameters: [
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "url",
      description: "The URL of the task",
    }),
  ],
  resultType: coda.ValueType.Object,
  schema: TaskSchema,
  execute: async function ([url], context) {
    let taskId = extractTaskId(url);
    let response = await context.fetcher.fetch({
      url: "https://api.todoist.com/rest/v2/tasks/" + taskId,
      method: "GET",
    });
    return formatTaskForSchema(response.body);
  },
});

// #endregion


// #region Column Formats

pack.addColumnFormat({
  name: "Project",
  formulaName: "Project",
  matchers: ProjectUrlPatterns,
});

pack.addColumnFormat({
  name: "Task",
  formulaName: "Task",
  matchers: TaskUrlPatterns,
});

// #endregion


// #region Actions

pack.addFormula({
  name: "AddProject",
  description: "Add a new Todoist project",
  parameters: [
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "name",
      description: "The name of the new project",
    }),
  ],
  resultType: coda.ValueType.String,
  isAction: true,
  execute: async function ([name], context) {
    let response = await context.fetcher.fetch({
      url: "https://api.todoist.com/rest/v2/projects",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: name,
      }),
    });
    return response.body.url;
  },
});

pack.addFormula({
  name: "AddTask",
  description: "Add a new task.",
  parameters: [
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "name",
      description: "The name of the task.",
    }),
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "projectId",
      description: "The ID of the project to add it to. If blank, " +
        "it will be added to the user's Inbox.",
      optional: true,
    }),
  ],
  resultType: coda.ValueType.String,
  isAction: true,
  execute: async function ([name, projectId], context) {
    let response = await context.fetcher.fetch({
      url: "https://api.todoist.com/rest/v2/tasks",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        content: name,
        project_id: projectId,
      }),
    });
    return response.body.url;
  },
});

pack.addFormula({
  name: "SetDueDate",
  description: "Change the due date of a task.",
  parameters: [
    coda.makeParameter({
      type: coda.ParameterType.String,
      name: "taskId",
      description: "The ID of the task.",
    }),
    coda.makeParameter({
      type: coda.ParameterType.Date,
      name: "date",
      description: "The date the task is due.",
    }),
    coda.makeParameter({
      type: coda.ParameterType.Boolean,
      name: "endOfDay",
      description:
        "If the task is due at the end of the day (vs a specific time).",
      suggestedValue: true,
    }),
  ],
  resultType: coda.ValueType.Object,
  // To update the existing row in a sync table, return the schema with an
  // identity matching the identityName on the sync table being updated, using
  // the helper function coda.withIdentity().
  schema: coda.withIdentity(TaskSchema, "Task"),
  isAction: true,
  execute: async function ([taskId, date, endOfDay = false], context) {
    let url = "https://api.todoist.com/rest/v2/tasks/" + taskId;
    let payload: any = {
      id: taskId,
    };
    if (endOfDay) {
      payload.due_date = date.toISOString().split("T")[0];
    } else {
      payload.due_datetime = date.toISOString();
    }
    let response = await context.fetcher.fetch({
      method: "POST",
      url: url,
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });
    return formatTaskForSchema(response.body);
  },
});

// #endregion


// #region Sync tables

pack.addSyncTable({
  name: "Projects",
  schema: ProjectSchema,
  identityName: "Project",
  formula: {
    name: "SyncProjects",
    description: "Sync projects",
    parameters: [],
    execute: async function ([], context) {
      let url = "https://api.todoist.com/rest/v2/projects";
      let response = await context.fetcher.fetch({
        method: "GET",
        url: url,
      });

      let results: any[] = [];
      for (let project of response.body) {
        results.push(formatProjectForSchema(project, true));
      }
      return {
        result: results,
      };
    },
    // Process row updates one at a time.
    maxUpdateBatchSize: 1,
    executeUpdate: async function (args, updates, context) {
      let update = updates[0];
      let project = update.newValue;
      let response = await context.fetcher.fetch({
        method: "POST",
        url: `https://api.todoist.com/rest/v2/projects/${project.id}`,
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(project),
      });
      let updated = formatProjectForSchema(response.body, true);

      return {
        result: [updated],
      };
    },
  },
});

pack.addSyncTable({
  name: "Tasks",
  schema: TaskSchema,
  identityName: "Task",
  formula: {
    name: "SyncTasks",
    description: "Sync tasks",
    parameters: [
      coda.makeParameter({
        type: coda.ParameterType.String,
        name: "filter",
        description: "A supported filter string. See the Todoist help center.",
        optional: true,
      }),
      coda.makeParameter({
        type: coda.ParameterType.String,
        name: "project",
        description: "Limit tasks to a specific project.",
        optional: true,
        autocomplete: async function (context, search) {
          let url = "https://api.todoist.com/rest/v2/projects";
          let response = await context.fetcher.fetch({
            method: "GET",
            url: url,
          });
          let projects = response.body;
          return coda.autocompleteSearchObjects(search, projects, "name", "id");
        },
      }),
    ],
    execute: async function ([filter, project], context) {
      let url = coda.withQueryParams("https://api.todoist.com/rest/v2/tasks", {
        filter: filter,
        project_id: project,
      });
      let response = await context.fetcher.fetch({
        method: "GET",
        url: url,
      });

      let results: any[] = [];
      for (let task of response.body) {
        results.push(formatTaskForSchema(task, true));
      }
      return {
        result: results,
      };
    },
    // Process row updates in batches.
    maxUpdateBatchSize: 10,
    executeUpdate: async function (args, updates, context) {
      // Generate the set of commands needed to process each update.
      let commandSets = updates.map(update => generateTaskCommands(update));

      // Send all of the commands to the sync endpoint.
      let response = await context.fetcher.fetch({
        method: "POST",
        url: "https://api.todoist.com/sync/v9/sync",
        form: {
          commands: JSON.stringify(commandSets.flat()),
        },
      });
      let statuses = response.body.sync_status;

      // Process the results, returning either an error or the updated task.
      // This is done async, so the fetches can be done in parallel.
      let jobs = updates.map(async (update, i) => {
        let taskId = update.newValue.id;
        let commands = commandSets[i];
        for (let command of commands) {
          let status = statuses[command.uuid];
          if (status.error) {
            return new coda.UserVisibleError(status.error);
          }
        }
        // If there were no errors, fetch the updated task and return it.
        let response = await context.fetcher.fetch({
          method: "GET",
          url: `https://api.todoist.com/rest/v2/tasks/${taskId}`,
          cacheTtlSecs: 0,
        });
        return formatTaskForSchema(response.body, true);
      });
      let results = await Promise.all(jobs);
      return {
        result: results,
      };
    },
  },
});

// Generate a list of API commands from a Task row update.
function generateTaskCommands(update: coda.GenericSyncUpdate): any[] {
  let commands: any[] = [];
  let { previousValue, newValue, updatedFields } = update;

  // Update the task.
  commands.push({
    type: "item_update",
    uuid: getUniqueId(),
    args: formatTaskForAPI(newValue),
  });

  // Update the parent project, if it has changed.
  if (updatedFields.includes("project")) {
    commands.push({
      type: "item_move",
      args: {
        id: newValue.id,
        project_id: newValue.project?.id,
      },
      uuid: getUniqueId(),
    });
  }

  // Update the completion status, if it's changed.
  if (previousValue.is_completed !== newValue.is_completed) {
    commands.push({
      type: newValue.is_completed ? "item_complete" : "item_uncomplete",
      uuid: getUniqueId(),
      args: {
        id: newValue.id,
      },
    });
  }
  return commands;
}

// #endregion


// #region Helper functions

function extractProjectId(projectUrl: string) {
  for (let pattern of ProjectUrlPatterns) {
    let matches = projectUrl.match(pattern);
    if (matches && matches[1]) {
      return matches[1];
    }
  }
  throw new coda.UserVisibleError("Invalid project URL: " + projectUrl);
}

function extractTaskId(taskUrl: string) {
  for (let pattern of TaskUrlPatterns) {
    let matches = taskUrl.match(pattern);
    if (matches && matches[1]) {
      return matches[1];
    }
  }
  throw new coda.UserVisibleError("Invalid task URL: " + taskUrl);
}

function getUniqueId() {
  return Math.random().toString(36);
}

// #endregion