diff --git a/dbludeau.TodoistNoteplanSync/README.md b/dbludeau.TodoistNoteplanSync/README.md index 9c2360832..2c43656eb 100644 --- a/dbludeau.TodoistNoteplanSync/README.md +++ b/dbludeau.TodoistNoteplanSync/README.md @@ -22,22 +22,64 @@ NOTE: All sync actions (other then content and status) can be turned on and off ## Available Commands - **/todoist sync everything** (alias **/tosa**): sync everything in Todoist to a folder in Noteplan. Every list in todoist will become a note in Noteplan. Use this if you want to use Todoist just as a conduit to get tasks into Noteplan. The folder used in Noteplan can be configured in settings. - **/todoist sync today** (alias **/tost**): sync tasks due today from Todoist to your daily note in Noteplan. A header can be configured in settings. -- **/todoist sync project** (alias **/tosp**): link a single list from Todoist to a note in Note plan using frontmatter. This command will sync the current project you have open. +- **/todoist sync project** (alias **/tosp**): link a single list from Todoist to a note in Note plan using frontmatter. This command will sync the current project you have open. You can optionally add a date filter argument: + - `/todoist sync project today` - only tasks due today + - `/todoist sync project overdue` - only overdue tasks + - `/todoist sync project current` - overdue + today (same as default setting) - **/todoist sync all projects** (alias **/tosa**): this will sync all projects that have been linked using frontmatter. - **/todoist sync all projects and today** (alias **/tosat** **/toast**): this will sync all projects and the today note. Running it as one comand instead of individually will check for duplicates. This command will sync all tasks from projects to their linked note, including tasks due today. It will sync all tasks from all projects in Todoist that are due today except for those already in the project notes to avoid duplication. ## Configuration - This plug in requires an API token from Todoist. These are available on both the free and paid plans. To get the token follow the instructions [here](https://todoist.com/help/articles/find-your-api-token) - You can configure a folder to use for syncing everything, headings that tasks will fall under and what details are synced. -- Sections in Todoist will become headers in Noteplan. See [here](https://todoist.com/help/articles/introduction-to-sections) to learn about sections in Todoist. +- Sections in Todoist will become headers in Noteplan. See [here](https://todoist.com/help/articles/introduction-to-sections) to learn about sections in Todoist. - Currently the API token is required, everything else is optional. -- To link a Todoist list to a Noteplan note, you need the list ID from Todoist. To get the ID, open www.todoist.com in a web browser and sign in so you can see your lists. Open the list you want to link to a Noteplan note. The list ID is at the end of the URL. For example, if the end of the Todoist.com URL is /app/project/2317353827, then you want the list ID of 2317353827. You would add frontmatter to the top of your note that would look like (see https://help.noteplan.co/article/136-templates for more information on frontmatter): + +### Project Date Filter +By default, project sync commands only fetch tasks that are **overdue or due today**. This keeps your notes focused on actionable items. You can change this behavior in settings: + +| Filter Option | Description | +|---------------|-------------| +| `all` | Sync all tasks regardless of due date | +| `today` | Only tasks due today | +| `overdue \| today` | Tasks that are overdue or due today (default) | +| `3 days` | Tasks due within the next 3 days | +| `7 days` | Tasks due within the next 7 days | + +This setting affects the following commands: +- `/todoist sync project` +- `/todoist sync all linked projects` +- `/todoist sync all linked projects and today` (project portion only) +- `/todoist sync everything` + +Note: The `/todoist sync today` command always filters by today regardless of this setting. + +### Linking a Todoist Project +To link a Todoist list to a Noteplan note, you need the list ID from Todoist. To get the ID, open www.todoist.com in a web browser and sign in so you can see your lists. Open the list you want to link to a Noteplan note. The list ID is at the end of the URL. For example, if the end of the Todoist.com URL is /app/project/2317353827, then you want the list ID of 2317353827. + +Add frontmatter to the top of your note (see https://help.noteplan.co/article/136-templates for more information on frontmatter): +``` +--- +todoist_id: 2317353827 +--- +``` + +### Per-Note Date Filter +You can override the default date filter for a specific note by adding `todoist_filter` to the frontmatter: ``` --- todoist_id: 2317353827 +todoist_filter: current --- ``` +Valid values for `todoist_filter`: `all`, `today`, `overdue`, `current` (same as overdue | today), `3 days`, `7 days` + +**Filter Priority:** +1. Command-line argument (e.g., `/todoist sync project today`) - highest +2. Frontmatter `todoist_filter` - second +3. Plugin settings "Date filter for project syncs" - default + ## Caveats, Warnings and Notes - All synced tasks in Noteplan rely on the Todoist ID being present and associated with the task. This is stored at the end of a synced task in the form of a link to www.todoist.com. - These links can be used to view the Todoist task on the web. diff --git a/dbludeau.TodoistNoteplanSync/plugin.json b/dbludeau.TodoistNoteplanSync/plugin.json index f2c251953..1914eb33e 100644 --- a/dbludeau.TodoistNoteplanSync/plugin.json +++ b/dbludeau.TodoistNoteplanSync/plugin.json @@ -47,11 +47,36 @@ "alias": [ "tosp" ], - "description": "Sync Todoist project (list) linked to the current Noteplan note using frontmatter", + "description": "Sync Todoist project (uses date filter from settings)", "jsFunction": "syncProject", - "arguments": [ - "" - ] + "arguments": [] + }, + { + "name": "todoist sync project today", + "alias": [ + "tospt" + ], + "description": "Sync Todoist project - only tasks due today", + "jsFunction": "syncProjectToday", + "arguments": [] + }, + { + "name": "todoist sync project overdue", + "alias": [ + "tospo" + ], + "description": "Sync Todoist project - only overdue tasks", + "jsFunction": "syncProjectOverdue", + "arguments": [] + }, + { + "name": "todoist sync project current", + "alias": [ + "tospc" + ], + "description": "Sync Todoist project - overdue + today tasks", + "jsFunction": "syncProjectCurrent", + "arguments": [] }, { "name": "todoist sync all linked projects", @@ -205,6 +230,25 @@ "description": "By default the sync will pull only tasks assigned to you. If you want to sync all unassigned tasks as well, check this box.", "default": false }, + { + "note": "================== PROJECT SYNC SETTINGS ========================" + }, + { + "type": "heading", + "title": "Project Sync Settings" + }, + { + "type": "separator" + }, + { + "type": "string", + "key": "projectDateFilter", + "title": "Date filter for project syncs", + "description": "Filter which tasks are synced based on due date. Choose 'all' to sync everything.", + "choices": ["all", "today", "overdue | today", "3 days", "7 days"], + "default": "overdue | today", + "required": false + }, { "note": "================== DEBUGGING SETTINGS ========================" }, diff --git a/dbludeau.TodoistNoteplanSync/src/NPPluginMain.js b/dbludeau.TodoistNoteplanSync/src/NPPluginMain.js index 2b0c03724..d6bcccd27 100644 --- a/dbludeau.TodoistNoteplanSync/src/NPPluginMain.js +++ b/dbludeau.TodoistNoteplanSync/src/NPPluginMain.js @@ -46,6 +46,7 @@ const setup: { teamAccount: boolean, addUnassigned: boolean, header: string, + projectDateFilter: string, newFolder: any, newToken: any, useTeamAccount: any, @@ -54,6 +55,7 @@ const setup: { syncTags: any, syncUnassigned: any, newHeader: any, + newProjectDateFilter: any, } = { token: '', folder: 'Todoist', @@ -63,6 +65,7 @@ const setup: { teamAccount: false, addUnassigned: false, header: '', + projectDateFilter: 'overdue | today', /** * @param {string} passedToken @@ -115,6 +118,12 @@ const setup: { set newHeader(passedHeader: string) { setup.header = passedHeader }, + /** + * @param {string} passedProjectDateFilter + */ + set newProjectDateFilter(passedProjectDateFilter: string) { + setup.projectDateFilter = passedProjectDateFilter + }, } const closed: Array = [] @@ -174,7 +183,7 @@ export async function syncEverything() { // grab the tasks and write them out with sections const id: string = projects[i].project_id - await projectSync(note, id) + await projectSync(note, id, null) } } @@ -188,14 +197,42 @@ export async function syncEverything() { logDebug(pluginJson, 'Plugin completed without errors') } +/** + * Parse the date filter argument from command line + * + * @param {string} arg - the argument passed to the command + * @returns {string | null} - the filter string or null if no override + */ +function parseDateFilterArg(arg: ?string): ?string { + if (!arg || arg.trim() === '') { + return null + } + const trimmed = arg.trim().toLowerCase() + if (trimmed === 'today') { + return 'today' + } else if (trimmed === 'overdue') { + return 'overdue' + } else if (trimmed === 'current') { + return 'overdue | today' + } + logWarn(pluginJson, `Unknown date filter argument: ${arg}. Using setting value.`) + return null +} + /** * Synchronize the current linked project. * + * @param {string} filterArg - optional date filter override (today, overdue, current) * @returns {Promise} A promise that resolves once synchronization is complete */ // eslint-disable-next-line require-await -export async function syncProject() { +export async function syncProject(filterArg: ?string) { setSettings() + const commandLineFilter = parseDateFilterArg(filterArg) + if (commandLineFilter) { + logInfo(pluginJson, `Using command-line filter override: ${commandLineFilter}`) + } + const note: ?TNote = Editor.note if (note) { // check to see if this has any frontmatter @@ -206,6 +243,15 @@ export async function syncProject() { if ('todoist_id' in frontmatter) { logDebug(pluginJson, `Frontmatter has link to Todoist project -> ${frontmatter.todoist_id}`) + // Determine filter priority: command-line > frontmatter > settings + let filterOverride = commandLineFilter + if (!filterOverride && 'todoist_filter' in frontmatter && frontmatter.todoist_filter) { + filterOverride = parseDateFilterArg(frontmatter.todoist_filter) + if (filterOverride) { + logInfo(pluginJson, `Using frontmatter filter: ${filterOverride}`) + } + } + const paragraphs: ?$ReadOnlyArray = note.paragraphs if (paragraphs) { paragraphs.forEach((paragraph) => { @@ -213,7 +259,7 @@ export async function syncProject() { }) } - await projectSync(note, frontmatter.todoist_id) + await projectSync(note, frontmatter.todoist_id, filterOverride) //close the tasks in Todoist if they are complete in Noteplan` closed.forEach(async (t) => { @@ -231,6 +277,30 @@ export async function syncProject() { } } +/** + * Sync project with 'today' filter + * @returns {Promise} + */ +export async function syncProjectToday(): Promise { + await syncProject('today') +} + +/** + * Sync project with 'overdue' filter + * @returns {Promise} + */ +export async function syncProjectOverdue(): Promise { + await syncProject('overdue') +} + +/** + * Sync project with 'current' (overdue | today) filter + * @returns {Promise} + */ +export async function syncProjectCurrent(): Promise { + await syncProject('current') +} + /** * Syncronize all linked projects. * @@ -288,7 +358,7 @@ async function syncThemAll() { id = id.trim() logInfo(pluginJson, `Matches up to Todoist project id: ${id}`) - await projectSync(note, id) + await projectSync(note, id, null) //close the tasks in Todoist if they are complete in Noteplan` closed.forEach(async (t) => { @@ -363,39 +433,131 @@ async function syncTodayTasks() { } } +/** + * Parse an ISO date string (YYYY-MM-DD) into a local Date object at midnight. + * This avoids timezone issues that occur when using new Date('YYYY-MM-DD'), + * which interprets the date as UTC midnight rather than local midnight. + * + * @param {string} isoDateString - date string in YYYY-MM-DD format + * @returns {Date} - Date object at local midnight + */ +function parseLocalDate(isoDateString: string): Date { + const parts = isoDateString.split('-') + const year = parseInt(parts[0], 10) + const month = parseInt(parts[1], 10) - 1 // JavaScript months are 0-indexed + const day = parseInt(parts[2], 10) + return new Date(year, month, day, 0, 0, 0, 0) +} + +/** + * Filter tasks by date based on the filter setting + * Note: Todoist API ignores filter param when project_id is specified, so we filter client-side + * + * @param {Array} tasks - array of task objects from Todoist + * @param {string} dateFilter - the date filter to apply (today, overdue, overdue | today, 3 days, 7 days, all) + * @returns {Array} - filtered tasks + */ +function filterTasksByDate(tasks: Array, dateFilter: ?string): Array { + if (!dateFilter || dateFilter === 'all') { + return tasks + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + + const threeDaysFromNow = new Date(today) + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3) + + const sevenDaysFromNow = new Date(today) + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7) + + return tasks.filter((task) => { + if (!task.due || !task.due.date) { + // Tasks without due dates: only include if filter is 'all' + return false + } + + // Parse the due date as a local date to avoid timezone issues + // Todoist returns dates in YYYY-MM-DD format + const dueDate = parseLocalDate(task.due.date) + + switch (dateFilter) { + case 'today': + return dueDate.getTime() === today.getTime() + case 'overdue': + return dueDate.getTime() < today.getTime() + case 'overdue | today': + return dueDate.getTime() <= today.getTime() + case '3 days': + return dueDate.getTime() <= threeDaysFromNow.getTime() + case '7 days': + return dueDate.getTime() <= sevenDaysFromNow.getTime() + default: + return true + } + }) +} + /** * Get Todoist project tasks and write them out one by one * * @param {TNote} note - note that will be written to * @param {string} id - Todoist project ID + * @param {string} filterOverride - optional date filter override * @returns {Promise} */ -async function projectSync(note: TNote, id: string): Promise { - const task_result = await pullTodoistTasksByProject(id) +async function projectSync(note: TNote, id: string, filterOverride: ?string): Promise { + const task_result = await pullTodoistTasksByProject(id, filterOverride) const tasks: Array = JSON.parse(task_result) - - tasks.results.forEach(async (t) => { + + // Determine which filter to use + const dateFilter = filterOverride ?? setup.projectDateFilter + + // Filter tasks client-side (Todoist API ignores filter when project_id is specified) + const filteredTasks = filterTasksByDate(tasks.results || [], dateFilter) + logInfo(pluginJson, `Filtered ${tasks.results?.length || 0} tasks to ${filteredTasks.length} based on filter: ${dateFilter}`) + + // Use for...of to properly await each task write + for (const t of filteredTasks) { await writeOutTask(note, t) - }) + } } /** * Pull todoist tasks from list matching the ID provided * * @param {string} project_id - the id of the Todoist project + * @param {string} filterOverride - optional date filter override (bypasses setting) * @returns {Promise} - promise that resolves into array of task objects or null */ -async function pullTodoistTasksByProject(project_id: string): Promise { +async function pullTodoistTasksByProject(project_id: string, filterOverride: ?string): Promise { if (project_id !== '') { - let filter = '' + const filterParts: Array = [] + + // Add date filter: use override if provided, otherwise use setting + const dateFilter = filterOverride ?? setup.projectDateFilter + if (dateFilter && dateFilter !== 'all') { + filterParts.push(dateFilter) + } + + // Add team account filter if applicable if (setup.useTeamAccount) { if (setup.addUnassigned) { - filter = '& filter=!assigned to: others' + filterParts.push('!assigned to: others') } else { - filter = '& filter=assigned to: me' + filterParts.push('assigned to: me') } } - const result = await fetch(`${todo_api}/tasks?project_id=${project_id}${filter}`, getRequestObject()) + + // Build the URL with proper encoding + let url = `${todo_api}/tasks?project_id=${project_id}` + if (filterParts.length > 0) { + const filterString = filterParts.join(' & ') + url = `${url}&filter=${encodeURIComponent(filterString)}` + } + + logDebug(pluginJson, `Fetching tasks from URL: ${url}`) + const result = await fetch(url, getRequestObject()) return result } return null @@ -556,6 +718,10 @@ function setSettings() { if ('headerToUse' in settings && settings.headerToUse !== '') { setup.newHeader = settings.headerToUse } + + if ('projectDateFilter' in settings && settings.projectDateFilter !== '') { + setup.newProjectDateFilter = settings.projectDateFilter + } } } diff --git a/dbludeau.TodoistNoteplanSync/src/index.js b/dbludeau.TodoistNoteplanSync/src/index.js index 461e45b94..a22b02479 100644 --- a/dbludeau.TodoistNoteplanSync/src/index.js +++ b/dbludeau.TodoistNoteplanSync/src/index.js @@ -15,7 +15,7 @@ // So you need to add a line below for each function that you want NP to have access to. // Typically, listed below are only the top-level plug-in functions listed in plugin.json -export { syncToday, syncEverything, syncProject, syncAllProjects, syncAllProjectsAndToday } from './NPPluginMain' +export { syncToday, syncEverything, syncProject, syncProjectToday, syncProjectOverdue, syncProjectCurrent, syncAllProjects, syncAllProjectsAndToday } from './NPPluginMain' // FETCH mocking for offline testing // If you want to use external server calls in your plugin, it can be useful to mock the server responses