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. +::: -![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 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. + ![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:}}` 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 - -![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 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).", + ); + }); +});