Skip to content
Open
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
53 changes: 51 additions & 2 deletions docs/docs/query-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ The query is defined as [YAML](https://yaml.org/) and there are a number of opti

### `filter`

The filter option is **required** and should be a valid [Todoist filter](https://todoist.com/help/articles/introduction-to-filters-V98wIH). Note that this must be the content of the filter, you cannot refer to a filter that already exists in your Todoist account.
The `filter` parameter is:
- **Required** when viewing active tasks (`viewCompleted: false`)
- **Optional** when viewing completed tasks (`viewCompleted: true`)

Should be a valid [Todoist filter](https://todoist.com/help/articles/introduction-to-filters-V98wIH). Note that this must be the content of the filter, you cannot refer to a filter that already exists in your Todoist account.

There are a few unsupported filters, these are tracked in [this GitHub issue](https://github.com/jamiebrynes7/obsidian-todoist-plugin/issues/34):

Expand All @@ -44,6 +48,8 @@ filter: "today | overdue"

The `autorefresh` option allows you to specify the number of seconds between automatic refreshes. This takes precedence over the plugin level setting. Omitting this option means the query will follow the plugin level settings.

Note: When viewing completed tasks with `autorefresh` enabled, the refresh interval must be at least 9 seconds due to Todoist API rate limits.

For example:

````
Expand Down Expand Up @@ -128,4 +134,47 @@ For example:
filter: "today | overdue"
show: none
```
````
````

### `viewCompleted`

The `viewCompleted` option allows you to include completed tasks in your query results. By default, this is set to `false`. When set to `true`, completed tasks will be shown according to the specified `completedSince` and `completedUntil` parameters.

For example:

````
```todoist
viewCompleted: true
completedLimit: 30
```
````

### `completedLimit`

The `completedLimit` option controls how many completed tasks to fetch. This value must not exceed 200 due to Todoist API limitations. If not specified, defaults to 30 tasks.

For example:

````
```todoist
viewCompleted: true
completedLimit: 30
```
````

### `completedSince` and `completedUntil`

These options allow you to specify a date range for completed tasks. Both parameters accept ISO datetime strings.

- `completedSince`: Only show tasks completed after this date (inclusive)
- `completedUntil`: Only show tasks completed before this date (inclusive)

For example:

````
```todoist
viewCompleted: true
completedSince: "2024-01-01T00:00:00"
completedUntil: "2024-03-31T23:59:59"
```
````
30 changes: 30 additions & 0 deletions plugin/src/api/domain/completedTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { DueDate } from "@/api/domain/dueDate";
import type { Priority } from "@/api/domain/task";

export type CompletedTask = {
id: string;
task_id: string;
project_id: string;
section_id: string | null;
content: string;
completed_at: string;
note_count: number;
meta_data: string | null;
item_object: {
due?: DueDate | null;
description: string;
priority: Priority;
labels: string[];
};
};

export type CompletedTasksResponse = {
items: CompletedTask[];
};

export type GetCompletedTasksParams = {
project_id?: string;
limit?: number;
until?: Date;
since?: Date;
};
57 changes: 56 additions & 1 deletion plugin/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type {
CompletedTask,
CompletedTasksResponse,
GetCompletedTasksParams,
} from "@/api/domain/completedTask";
import type { Label } from "@/api/domain/label";
import type { Project } from "@/api/domain/project";
import type { Section } from "@/api/domain/section";
Expand All @@ -10,6 +15,8 @@ import snakify from "snakify-ts";
export class TodoistApiClient {
private token: string;
private fetcher: WebFetcher;
private readonly syncBaseUrl = "https://api.todoist.com/sync/v9";
private readonly restBaseUrl = "https://api.todoist.com/rest/v2";

constructor(token: string, fetcher: WebFetcher) {
this.token = token;
Expand Down Expand Up @@ -52,9 +59,57 @@ export class TodoistApiClient {
return camelize(JSON.parse(response.body)) as Label[];
}

/**
* Gets a list of completed tasks from Todoist using the sync API.
*/
public async getCompletedTasks(params?: GetCompletedTasksParams): Promise<CompletedTask[]> {
const queryParams = new URLSearchParams();

if (params) {
if (params.project_id) queryParams.set("project_id", params.project_id);
if (params.limit) queryParams.set("limit", params.limit.toString());
if (params.until) queryParams.set("until", params.until.toISOString());
if (params.since) queryParams.set("since", params.since.toISOString());
}

queryParams.set("annotate_items", "true");

const url = `${this.syncBaseUrl}/completed/get_all${
queryParams.toString() ? `?${queryParams.toString()}` : ""
}`;

debug({
msg: "Sending Todoist API request",
context: Object.fromEntries(queryParams.entries()),
});
Comment on lines +81 to +84
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we debug log the RequestParams that gets passed into fetcher.fetch like what happens in do?


const response = await this.fetcher.fetch({
url,
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
},
});

debug({
msg: "Received Todoist API response",
context: response,
});

if (response.statusCode >= 400) {
throw new TodoistApiError(
{ url, method: "GET", headers: { Authorization: `Bearer ${this.token}` } },
response,
);
}

const data = JSON.parse(response.body) as CompletedTasksResponse;
return data.items;
}

private async do(path: string, method: string, json?: object): Promise<WebResponse> {
const params: RequestParams = {
url: `https://api.todoist.com/rest/v2${path}`,
url: `${this.restBaseUrl}${path}`,
method: method,
headers: {
Authorization: `Bearer ${this.token}`,
Expand Down
66 changes: 55 additions & 11 deletions plugin/src/data/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { type TodoistApiClient, TodoistApiError } from "@/api";
import type { CompletedTask, GetCompletedTasksParams } from "@/api/domain/completedTask";
import type { Label, LabelId } from "@/api/domain/label";
import type { Project, ProjectId } from "@/api/domain/project";
import type { Section, SectionId } from "@/api/domain/section";
import type { Task as ApiTask, CreateTaskParams, TaskId } from "@/api/domain/task";
import { Repository, type RepositoryReader } from "@/data/repository";
import { SubscriptionManager, type UnsubscribeCallback } from "@/data/subscriptions";
import type { Task } from "@/data/task";
import type { Query } from "@/query/query";
import { Maybe } from "@/utils/maybe";

export enum QueryErrorKind {
Expand Down Expand Up @@ -93,30 +95,41 @@ export class TodoistAdapter {
};
}

public subscribe(query: string, callback: OnSubscriptionChange): [UnsubscribeCallback, Refresh] {
public subscribe(query: Query, callback: OnSubscriptionChange): [UnsubscribeCallback, Refresh] {
const fetcher = this.buildQueryFetcher(query);
const subscription = new Subscription(callback, fetcher, () => true);
return [this.subscriptions.subscribe(subscription), subscription.update];
}

private buildQueryFetcher(query: string): SubscriptionFetcher {
private buildQueryFetcher(query: Query): SubscriptionFetcher {
return async () => {
if (!this.api.hasValue()) {
return undefined;
}
const data = await this.api.withInner((api) => api.getTasks(query));
const hydrated = data.map((t) => this.hydrate(t));
return hydrated;

let result: Task[];
if (query.viewCompleted) {
const params: GetCompletedTasksParams = {
limit: query.completedLimit,
since: query.completedSince,
until: query.completedUntil,
};

const data = await this.api.withInner((api) => api.getCompletedTasks(params));
result = data.map((t) => this.hydrateCompletedTask(t));
} else {
const data = await this.api.withInner((api) => api.getTasks(query.filter));
result = data.map((t) => this.hydrate(t));
}

return result;
};
}

private hydrate(apiTask: ApiTask): Task {
const project = this.projects.byId(apiTask.projectId);
const section = apiTask.sectionId
? (this.sections.byId(apiTask.sectionId) ?? makeUnknownSection(apiTask.sectionId))
: undefined;

const labels = apiTask.labels.map((id) => this.labels.byName(id) ?? makeUnknownLabel());
const project = this.getProjectById(apiTask.projectId);
const section = this.getSectionById(apiTask.sectionId);
const labels = this.getLabelsByIds(apiTask.labels);

return {
id: apiTask.id,
Expand All @@ -137,6 +150,37 @@ export class TodoistAdapter {
};
}

private hydrateCompletedTask(apiCompletedTask: CompletedTask): Task {
const project = this.getProjectById(apiCompletedTask.project_id);
const section = this.getSectionById(apiCompletedTask.section_id);
const labels = this.getLabelsByIds(apiCompletedTask.item_object.labels);

return {
id: apiCompletedTask.task_id,
createdAt: apiCompletedTask.completed_at,
content: apiCompletedTask.content,
description: apiCompletedTask.item_object.description,
priority: apiCompletedTask.item_object.priority,
due: apiCompletedTask.item_object.due ?? undefined,

project: project ?? makeUnknownProject(apiCompletedTask.project_id),
section: section,
labels: labels,
};
}

private getProjectById(projectId: string): Project | undefined {
return this.projects.byId(projectId);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we include the makeUnknownProject call here like with section/labels?

}

private getSectionById(sectionId: string | null): Section | undefined {
return sectionId ? (this.sections.byId(sectionId) ?? makeUnknownSection(sectionId)) : undefined;
}

private getLabelsByIds(labels: string[]): Label[] {
return labels.map((id) => this.labels.byName(id) ?? makeUnknownLabel());
}

private async closeTask(id: TaskId): Promise<void> {
this.tasksPendingClose.push(id);

Expand Down
4 changes: 2 additions & 2 deletions plugin/src/data/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Priority, TaskId } from "@/api/domain/task";

export type Task = {
id: TaskId;
createdAt: string;
createdAt?: string;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are using the completed task's completed_at for this, so I think this doesn't need to be made optional


content: string;
description: string;
Expand All @@ -19,5 +19,5 @@ export type Task = {
priority: Priority;

due?: DueDate;
order: number;
order?: number;
};
7 changes: 4 additions & 3 deletions plugin/src/data/transformations/sorting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function compareTask<T extends Task>(self: T, other: T, sorting: SortingVariant)
case SortingVariant.DateDescending:
return -compareTaskDate(self, other);
case SortingVariant.Order:
return self.order - other.order;
return (self.order ?? 0) - (other.order ?? 0);
case SortingVariant.DateAdded:
return compareTaskDateAdded(self, other);
case SortingVariant.DateAddedDescending:
Expand Down Expand Up @@ -89,8 +89,9 @@ function compareTaskDate<T extends Task>(self: T, other: T): number {
}

function compareTaskDateAdded<T extends Task>(self: T, other: T): number {
const selfDate = parseAbsoluteToLocal(self.createdAt);
const otherDate = parseAbsoluteToLocal(other.createdAt);
const now = new Date().toISOString();
const selfDate = parseAbsoluteToLocal(self.createdAt ?? now);
const otherDate = parseAbsoluteToLocal(other.createdAt ?? now);

return selfDate.compare(otherDate) < 0 ? -1 : 1;
}
Loading