diff --git a/docs/public/scripts/TodoistScript.js b/docs/public/scripts/TodoistScript.js
index ca26d327f..e408cbad4 100644
--- a/docs/public/scripts/TodoistScript.js
+++ b/docs/public/scripts/TodoistScript.js
@@ -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;
-}
\ No newline at end of file
+ return {request, getAll};
+}
diff --git a/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md b/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md
index fa72c1196..bb54ae370 100644
--- a/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md
+++ b/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md
@@ -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 Todoist Script (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 Todoist Script, 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
@@ -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.
+:::
-
+## 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 Todoist Script 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.
+ 
-- _Capture to:_ the path to the file where you want to store the tasks.
-- _Capture format:_ Enabled - and in the format, write `{{MACRO:}}` 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:
-`- [ ] 📆 `
+`- [ ] 📅 `
-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
-
-
+- **"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
diff --git a/docs/src/content/docs/docs/Images/Todoist-GetAllTasksFromProject.png b/docs/src/content/docs/docs/Images/Todoist-GetAllTasksFromProject.png
deleted file mode 100644
index 61771624b..000000000
Binary files a/docs/src/content/docs/docs/Images/Todoist-GetAllTasksFromProject.png and /dev/null differ
diff --git a/docs/src/content/docs/docs/Images/Todoist-ScriptSettings.png b/docs/src/content/docs/docs/Images/Todoist-ScriptSettings.png
new file mode 100644
index 000000000..095cc2f72
Binary files /dev/null and b/docs/src/content/docs/docs/Images/Todoist-ScriptSettings.png differ
diff --git a/tests/examples/todoistScript.test.ts b/tests/examples/todoistScript.test.ts
new file mode 100644
index 000000000..cfaac5e9d
--- /dev/null
+++ b/tests/examples/todoistScript.test.ts
@@ -0,0 +1,273 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import moment from "moment";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const scriptPath = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../docs/public/scripts/TodoistScript.js",
+);
+
+type ScriptFn = (params: unknown, settings: Record) => Promise;
+type TodoistScript = {
+ entry: ScriptFn;
+ settings: { options: Record };
+ SelectFromAllTasks: ScriptFn;
+ GetAllTasksFromProject: ScriptFn;
+ GetAllTasksFromSection: ScriptFn;
+};
+
+const TOKEN = "Todoist API token";
+const COMPLETE = "Complete imported tasks in Todoist";
+
+function loadScript(): TodoistScript {
+ const source = readFileSync(scriptPath, "utf8");
+ const module = { exports: {} as TodoistScript };
+ new Function("module", "exports", source)(module, module.exports);
+ return module.exports;
+}
+
+type Task = {
+ id: string;
+ content: string;
+ project_id: string;
+ section_id: string | null;
+ due: { date: string } | null;
+};
+
+const projects = [
+ { id: "p-home", name: "Home" },
+ { id: "p-work", name: "Work" },
+];
+const sections = [
+ { id: "s-errands", project_id: "p-home", name: "Errands" },
+ { id: "s-garden", project_id: "p-home", name: "Garden" },
+];
+// More tasks than one page, so a client that ignores `next_cursor` misses some.
+const tasks: Task[] = [
+ { id: "t1", content: "Buy oat milk", project_id: "p-home", section_id: "s-errands", due: { date: "2031-01-15" } },
+ { id: "t2", content: "Return library books", project_id: "p-home", section_id: "s-errands", due: null },
+ { id: "t3", content: "Water the plants", project_id: "p-home", section_id: "s-garden", due: { date: "2031-03-20T09:00:00" } },
+ { id: "t4", content: "Plan picnic", project_id: "p-home", section_id: null, due: null },
+ { id: "t5", content: "Send report", project_id: "p-work", section_id: null, due: { date: "2031-02-03T23:30:00Z" } },
+ { id: "t6", content: "Plan picnic", project_id: "p-work", section_id: null, due: null },
+];
+
+const PAGE_SIZE = 2;
+
+function createTodoist(options: { status?: number; failClose?: string[] } = {}) {
+ const closed: string[] = [];
+ const requests: Array<{ method: string; url: string; auth?: string }> = [];
+
+ const requestUrl = vi.fn(
+ async (request: { url: string; method?: string; headers?: Record }) => {
+ const method = request.method ?? "GET";
+ requests.push({ method, url: request.url, auth: request.headers?.Authorization });
+ if (options.status) return { status: options.status, text: "", json: null };
+
+ const url = new URL(request.url);
+ expect(url.origin + url.pathname.replace(/\/[^/]+(\/close)$/, "/:id$1")).toMatch(
+ /^https:\/\/api\.todoist\.com\/api\/v1\/(tasks|projects|sections|tasks\/:id\/close)$/,
+ );
+
+ const close = url.pathname.match(/\/tasks\/([^/]+)\/close$/);
+ if (close && method === "POST") {
+ if (options.failClose?.includes(close[1])) return { status: 500, text: "", json: null };
+ closed.push(close[1]);
+ return { status: 204, text: "", json: null };
+ }
+
+ const collection = { tasks, projects, sections }[url.pathname.split("/").pop() as string];
+ if (!collection || method !== "GET") return { status: 404, text: "", json: null };
+ expect(Number(url.searchParams.get("limit"))).toBeLessThanOrEqual(200);
+ const start = Number(url.searchParams.get("cursor") ?? 0);
+ const end = start + PAGE_SIZE;
+ const body = {
+ results: collection.slice(start, end),
+ next_cursor: end < collection.length ? String(end) : null,
+ };
+ return { status: 200, text: JSON.stringify(body), json: body };
+ },
+ );
+
+ return { requestUrl, closed, requests };
+}
+
+function setup(options: {
+ settings?: Record;
+ suggest?: (labels: string[]) => number;
+ check?: (labels: string[]) => string[];
+ status?: number;
+ failClose?: string[];
+} = {}) {
+ const todoist = createTodoist({ status: options.status, failClose: options.failClose });
+ const notices: string[] = [];
+ const suggester = vi.fn(async (labels: string[], items: unknown[]) =>
+ items[options.suggest ? options.suggest(labels) : 0],
+ );
+ const checkboxPrompt = vi.fn(async (labels: string[]) => options.check?.(labels) ?? []);
+ const params = {
+ obsidian: {
+ requestUrl: todoist.requestUrl,
+ Notice: class {
+ constructor(message: string) {
+ notices.push(message);
+ }
+ },
+ },
+ quickAddApi: { suggester, checkboxPrompt },
+ };
+ const settings = { [TOKEN]: "test-token", [COMPLETE]: true, ...options.settings };
+ return { params, settings, notices, suggester, checkboxPrompt, ...todoist };
+}
+
+function localDate(iso: string) {
+ const d = new Date(iso);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+describe("Todoist example script", () => {
+ beforeEach(() => {
+ (window as unknown as { moment: typeof moment }).moment = moment;
+ });
+ afterEach(() => {
+ delete (window as unknown as { moment?: typeof moment }).moment;
+ });
+
+ it("declares the API token as a secret setting and completion as an opt-out checkbox", () => {
+ const { settings } = loadScript();
+ expect(settings.options[TOKEN]).toMatchObject({ type: "secret", id: "todoist-api-token" });
+ expect(settings.options[COMPLETE]).toMatchObject({ type: "checkbox", defaultValue: true });
+ });
+
+ it("imports every task in a project, across pages and sections, then completes them", async () => {
+ const ctx = setup({ suggest: (labels) => labels.indexOf("Home (4)") });
+
+ const output = await loadScript().GetAllTasksFromProject(ctx.params, ctx.settings);
+
+ expect(output).toBe(
+ [
+ "- [ ] Buy oat milk 📅 2031-01-15",
+ "- [ ] Return library books",
+ "- [ ] Water the plants 📅 2031-03-20",
+ "- [ ] Plan picnic",
+ "",
+ ].join("\n"),
+ );
+ expect(ctx.suggester.mock.calls[0][0]).toEqual(["Home (4)", "Work (2)"]);
+ expect(ctx.closed).toEqual(["t1", "t2", "t3", "t4"]);
+ expect(ctx.notices).toEqual(["Added 4 tasks from 'Home'."]);
+ expect(new Set(ctx.requests.map((r) => r.auth))).toEqual(new Set(["Bearer test-token"]));
+ });
+
+ it("imports only the chosen section's tasks", async () => {
+ const ctx = setup({ suggest: (labels) => labels.indexOf("Home > Garden (1)") });
+
+ const output = await loadScript().GetAllTasksFromSection(ctx.params, ctx.settings);
+
+ expect(ctx.suggester.mock.calls[0][0]).toEqual(["Home > Errands (2)", "Home > Garden (1)"]);
+ expect(output).toBe("- [ ] Water the plants 📅 2031-03-20\n");
+ expect(ctx.closed).toEqual(["t3"]);
+ });
+
+ it("imports only the checked tasks, telling apart tasks with the same name", async () => {
+ const ctx = setup({ check: (labels) => [labels[5]] });
+
+ const output = await loadScript().SelectFromAllTasks(ctx.params, ctx.settings);
+
+ expect(ctx.checkboxPrompt.mock.calls[0][0]).toHaveLength(6);
+ expect(output).toBe("- [ ] Plan picnic\n");
+ expect(ctx.closed).toEqual(["t6"]);
+ });
+
+ it("writes a fixed-time-zone due date (UTC in the API) as the local date", async () => {
+ const originalTz = process.env.TZ;
+ process.env.TZ = "Asia/Tokyo"; // 2031-02-03T23:30Z is 08:30 on Feb 4 in Tokyo
+ try {
+ const ctx = setup({ check: (labels) => labels.filter((l) => l.endsWith("Send report")) });
+
+ const output = await loadScript().SelectFromAllTasks(ctx.params, ctx.settings);
+
+ expect(output).toBe("- [ ] Send report 📅 2031-02-04\n");
+ } finally {
+ if (originalTz === undefined) delete process.env.TZ;
+ else process.env.TZ = originalTz;
+ }
+ });
+
+ it("leaves tasks open in Todoist when completion is turned off", async () => {
+ const ctx = setup({ settings: { [COMPLETE]: false }, suggest: (labels) => labels.indexOf("Work (2)") });
+
+ const output = await loadScript().GetAllTasksFromProject(ctx.params, ctx.settings);
+
+ expect(output).toBe(`- [ ] Send report 📅 ${localDate("2031-02-03T23:30:00Z")}\n- [ ] Plan picnic\n`);
+ expect(ctx.closed).toEqual([]);
+ expect(ctx.requests.every((r) => r.method === "GET")).toBe(true);
+ });
+
+ it("still returns the capture and completes the rest when a mid-batch close fails", async () => {
+ const ctx = setup({ failClose: ["t2"], suggest: (labels) => labels.indexOf("Home (4)") });
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ const output = await loadScript().GetAllTasksFromProject(ctx.params, ctx.settings);
+
+ expect(output).toBe(
+ [
+ "- [ ] Buy oat milk 📅 2031-01-15",
+ "- [ ] Return library books",
+ "- [ ] Water the plants 📅 2031-03-20",
+ "- [ ] Plan picnic",
+ "",
+ ].join("\n"),
+ );
+ expect(ctx.closed).toEqual(["t1", "t3", "t4"]);
+ expect(ctx.notices).toEqual([
+ "Could not complete 1 of 4 imported tasks in Todoist; they are still open there.",
+ "Added 4 tasks from 'Home'.",
+ ]);
+ });
+
+ it("imports nothing when no task is checked", async () => {
+ const ctx = setup({ check: () => [] });
+
+ const output = await loadScript().SelectFromAllTasks(ctx.params, ctx.settings);
+
+ expect(output).toBe("");
+ expect(ctx.closed).toEqual([]);
+ });
+
+ it("asks which export to run when the macro calls the script without `::`", async () => {
+ const ctx = setup({
+ suggest: (labels) =>
+ labels.includes("GetAllTasksFromSection") ? labels.indexOf("GetAllTasksFromSection") : 0,
+ });
+
+ const output = await loadScript().entry(ctx.params, ctx.settings);
+
+ expect(ctx.suggester.mock.calls[0][0]).toEqual([
+ "SelectFromAllTasks",
+ "GetAllTasksFromProject",
+ "GetAllTasksFromSection",
+ ]);
+ expect(output).toBe("- [ ] Buy oat milk 📅 2031-01-15\n- [ ] Return library books\n");
+ });
+
+ it("explains a missing token without calling Todoist", async () => {
+ const ctx = setup({ settings: { [TOKEN]: "" } });
+
+ await expect(loadScript().GetAllTasksFromProject(ctx.params, ctx.settings)).rejects.toThrow(
+ /Add your Todoist API token/,
+ );
+ expect(ctx.requestUrl).not.toHaveBeenCalled();
+ });
+
+ it("explains a rejected token", async () => {
+ const ctx = setup({ status: 401 });
+
+ await expect(loadScript().SelectFromAllTasks(ctx.params, ctx.settings)).rejects.toThrow(
+ "Todoist rejected the API token (HTTP 401).",
+ );
+ });
+});