Skip to content

Commit cebd2af

Browse files
committed
plugin: use sync API for projects/sections/labels
This reduces the number of API calls that we need to do as part of the metadata refresh
1 parent 96e70f5 commit cebd2af

File tree

17 files changed

+146
-78
lines changed

17 files changed

+146
-78
lines changed

plugin/src/api/domain/label.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export type Label = {
44
id: LabelId;
55
name: string;
66
color: string;
7+
isDeleted: boolean;
78
};

plugin/src/api/domain/project.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ export type Project = {
44
id: ProjectId;
55
parentId: ProjectId | null;
66
name: string;
7-
order: number;
7+
childOrder: number;
88
inboxProject: boolean;
99
color: string;
10+
isDeleted: boolean;
11+
isArchived: boolean;
1012
};

plugin/src/api/domain/section.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ export type Section = {
66
id: SectionId;
77
projectId: ProjectId;
88
name: string;
9-
order: number;
9+
sectionOrder: number;
10+
isDeleted: boolean;
11+
isArchived: boolean;
1012
};

plugin/src/api/domain/sync.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Label } from "@/api/domain/label";
2+
import type { Project } from "@/api/domain/project";
3+
import type { Section } from "@/api/domain/section";
4+
5+
export type SyncResponse = {
6+
syncToken: SyncToken;
7+
labels: Label[];
8+
projects: Project[];
9+
sections: Section[];
10+
};
11+
12+
export type SyncToken = string;

plugin/src/api/index.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import camelize from "camelize-ts";
22
import snakify from "snakify-ts";
33

4-
import type { Label } from "@/api/domain/label";
5-
import type { Project } from "@/api/domain/project";
6-
import type { Section } from "@/api/domain/section";
4+
import type { SyncResponse, SyncToken } from "@/api/domain/sync";
75
import type { CreateTaskParams, Task, TaskId } from "@/api/domain/task";
86
import type { UserInfo } from "@/api/domain/user";
97
import { type RequestParams, StatusCode, type WebFetcher, type WebResponse } from "@/api/fetcher";
@@ -44,23 +42,20 @@ export class TodoistApiClient {
4442
await this.do(`/tasks/${id}/close`, "POST", {});
4543
}
4644

47-
public async getProjects(): Promise<Project[]> {
48-
return await this.doPaginated<Project>("/projects");
49-
}
50-
51-
public async getSections(): Promise<Section[]> {
52-
return await this.doPaginated<Section>("/sections");
53-
}
54-
55-
public async getLabels(): Promise<Label[]> {
56-
return await this.doPaginated<Label>("/labels");
57-
}
58-
5945
public async getUser(): Promise<UserInfo> {
6046
const response = await this.do("/user", "GET", {});
6147
return camelize(JSON.parse(response.body)) as UserInfo;
6248
}
6349

50+
public async sync(token: SyncToken): Promise<SyncResponse> {
51+
const queryParams = snakify({
52+
syncToken: token,
53+
resourceTypes: JSON.stringify(["projects", "labels", "sections"]),
54+
});
55+
const response = await this.do("/sync", "POST", { queryParams });
56+
return camelize(JSON.parse(response.body)) as SyncResponse;
57+
}
58+
6459
private async doPaginated<T>(path: string, params?: Record<string, string>): Promise<T[]> {
6560
const allResults: T[] = [];
6661
let cursor: string | null = null;

plugin/src/data/index.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type TodoistApiClient, TodoistApiError } from "@/api";
22
import type { Label, LabelId } from "@/api/domain/label";
33
import type { Project, ProjectId } from "@/api/domain/project";
44
import type { Section, SectionId } from "@/api/domain/section";
5+
import type { SyncToken } from "@/api/domain/sync";
56
import type { Task as ApiTask, CreateTaskParams, TaskId } from "@/api/domain/task";
67
import type { UserInfo } from "@/api/domain/user";
78
import { StatusCode, StatusCodes } from "@/api/fetcher";
@@ -31,12 +32,6 @@ type DataAccessor = {
3132
labels: RepositoryReader<LabelId, Label>;
3233
};
3334

34-
class LabelsRepository extends Repository<LabelId, Label> {
35-
byName(name: string): Label | undefined {
36-
return [...this.iter()].find((label) => label.name === name);
37-
}
38-
}
39-
4035
export class TodoistAdapter {
4136
public actions = {
4237
closeTask: async (id: TaskId) => await this.closeTask(id),
@@ -47,18 +42,19 @@ export class TodoistAdapter {
4742
private readonly api: Maybe<TodoistApiClient> = Maybe.Empty();
4843
private readonly projects: Repository<ProjectId, Project>;
4944
private readonly sections: Repository<SectionId, Section>;
50-
private readonly labels: LabelsRepository;
45+
private readonly labels: Repository<LabelId, Label>;
5146
private readonly subscriptions: SubscriptionManager<Subscription>;
5247

5348
private readonly tasksPendingClose: TaskId[];
5449
private userInfo: UserInfo | undefined;
5550

5651
private hasSynced = false;
52+
private syncToken: SyncToken = "*";
5753

5854
constructor() {
59-
this.projects = new Repository(() => this.api.withInner((api) => api.getProjects()));
60-
this.sections = new Repository(() => this.api.withInner((api) => api.getSections()));
61-
this.labels = new LabelsRepository(() => this.api.withInner((api) => api.getLabels()));
55+
this.projects = new Repository<ProjectId, Project>();
56+
this.sections = new Repository<SectionId, Section>();
57+
this.labels = new Repository<LabelId, Label>();
6258
this.subscriptions = new SubscriptionManager<Subscription>();
6359
this.tasksPendingClose = [];
6460
}
@@ -81,12 +77,7 @@ export class TodoistAdapter {
8177
return;
8278
}
8379

84-
await Promise.all([
85-
this.syncUserInfo(),
86-
this.projects.sync(),
87-
this.sections.sync(),
88-
this.labels.sync(),
89-
]);
80+
await Promise.all([this.syncUserInfo(), this.syncMetadata()]);
9081

9182
for (const subscription of this.subscriptions.list()) {
9283
await subscription.update();
@@ -106,6 +97,23 @@ export class TodoistAdapter {
10697
}
10798
}
10899

100+
private async syncMetadata(): Promise<void> {
101+
try {
102+
if (!this.api.hasValue()) {
103+
return;
104+
}
105+
106+
const response = await this.api.withInner((api) => api.sync(this.syncToken));
107+
108+
this.projects.applyDiff(response.projects);
109+
this.sections.applyDiff(response.sections);
110+
this.labels.applyDiff(response.labels);
111+
this.syncToken = response.syncToken;
112+
} catch (error) {
113+
console.error("Failed to sync metadata:", error);
114+
}
115+
}
116+
109117
public data(): DataAccessor {
110118
return {
111119
projects: this.projects,
@@ -191,9 +199,11 @@ const makeUnknownProject = (id: string): Project => {
191199
id,
192200
parentId: null,
193201
name: "Unknown Project",
194-
order: Number.MAX_SAFE_INTEGER,
202+
childOrder: Number.MAX_SAFE_INTEGER,
195203
inboxProject: false,
196204
color: "grey",
205+
isDeleted: false,
206+
isArchived: false,
197207
};
198208
};
199209

@@ -202,7 +212,9 @@ const makeUnknownSection = (id: string): Section => {
202212
id,
203213
projectId: "unknown-project",
204214
name: "Unknown Section",
205-
order: Number.MAX_SAFE_INTEGER,
215+
sectionOrder: Number.MAX_SAFE_INTEGER,
216+
isDeleted: false,
217+
isArchived: false,
206218
};
207219
};
208220

@@ -211,6 +223,7 @@ const makeUnknownLabel = (): Label => {
211223
id: "unknown-label",
212224
name: "Unknown Label",
213225
color: "grey",
226+
isDeleted: false,
214227
};
215228
};
216229

plugin/src/data/repository.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,66 @@
1-
export interface RepositoryReader<T, U extends { id: T }> {
1+
interface RepositoryItem<Id> {
2+
id: Id;
3+
name: string;
4+
isDeleted: boolean;
5+
isArchived?: boolean;
6+
}
7+
8+
export interface RepositoryReader<T, U> {
29
byId(id: T): U | undefined;
10+
byName(name: string): U | undefined;
311
iter(): IterableIterator<U>;
4-
}
512

6-
export class Repository<T, U extends { id: T }> implements RepositoryReader<T, U> {
7-
private readonly data: Map<T, U> = new Map();
8-
private readonly fetchData: () => Promise<U[]>;
13+
iterActive(): IterableIterator<U>;
14+
}
915

10-
constructor(refreshData: () => Promise<U[]>) {
11-
this.fetchData = refreshData;
12-
}
16+
export interface RepositoryWriter<U> {
17+
applyDiff(changed: U[]): void;
18+
}
1319

14-
public async sync(): Promise<void> {
15-
try {
16-
const items = await this.fetchData();
20+
export class Repository<T, U extends RepositoryItem<T>>
21+
implements RepositoryReader<T, U>, RepositoryWriter<U>
22+
{
23+
private readonly data: Map<T, U> = new Map();
1724

18-
this.data.clear();
19-
for (const elem of items) {
20-
this.data.set(elem.id, elem);
25+
public applyDiff(changed: U[]): void {
26+
for (const item of changed) {
27+
if (item.isDeleted) {
28+
this.data.delete(item.id);
29+
continue;
2130
}
22-
} catch (error: unknown) {
23-
console.error(`Failed to update repository: ${error}`);
31+
this.data.set(item.id, item);
2432
}
2533
}
2634

2735
public byId(id: T): U | undefined {
2836
return this.data.get(id);
2937
}
3038

39+
public byName(name: string): U | undefined {
40+
for (const item of this.iter()) {
41+
if (item.name === name) {
42+
return item;
43+
}
44+
}
45+
46+
return undefined;
47+
}
48+
3149
public iter(): IterableIterator<U> {
3250
return this.data.values();
3351
}
52+
53+
public *iterActive(): IterableIterator<U> {
54+
for (const item of this.iter()) {
55+
if (item.isDeleted) {
56+
continue;
57+
}
58+
59+
if (item.isArchived ?? false) {
60+
continue;
61+
}
62+
63+
yield item;
64+
}
65+
}
3466
}

plugin/src/data/transformations/grouping.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ function makeProject(id: string, opts?: Partial<Project>): Project {
4646
id,
4747
parentId: opts?.parentId ?? null,
4848
name: opts?.name ?? "Project",
49-
order: opts?.order ?? 1,
49+
childOrder: opts?.childOrder ?? 1,
5050
inboxProject: false,
5151
color: "grey",
52+
isDeleted: false,
53+
isArchived: false,
5254
};
5355
}
5456

@@ -57,7 +59,9 @@ function makeSection(id: string, projectId: string, opts?: Partial<Section>): Se
5759
id,
5860
projectId,
5961
name: opts?.name ?? "Section",
60-
order: opts?.order ?? 1,
62+
sectionOrder: opts?.sectionOrder ?? 1,
63+
isDeleted: false,
64+
isArchived: false,
6165
};
6266
}
6367

@@ -73,6 +77,7 @@ function makeLabel(name: string): Label {
7377
id: name,
7478
name,
7579
color: "grey",
80+
isDeleted: false,
7681
};
7782
}
7883

@@ -187,8 +192,8 @@ describe("group by priority", () => {
187192
});
188193

189194
describe("group by project", () => {
190-
const projectOne = makeProject("1", { name: "Project One", order: 1 });
191-
const projectTwo = makeProject("2", { name: "Project Two", order: 2 });
195+
const projectOne = makeProject("1", { name: "Project One", childOrder: 1 });
196+
const projectTwo = makeProject("2", { name: "Project Two", childOrder: 2 });
192197

193198
const testcases: TestCase[] = [
194199
{
@@ -243,14 +248,14 @@ describe("group by project", () => {
243248
});
244249

245250
describe("group by section", () => {
246-
const projectOne = makeProject("1", { name: "Project One", order: 1 });
247-
const projectTwo = makeProject("2", { name: "Project Two", order: 2 });
251+
const projectOne = makeProject("1", { name: "Project One", childOrder: 1 });
252+
const projectTwo = makeProject("2", { name: "Project Two", childOrder: 2 });
248253

249-
const sectionOne = makeSection("1", "1", { name: "Section One", order: 1 });
250-
const sectionTwo = makeSection("1", "2", { name: "Section Two", order: 2 });
254+
const sectionOne = makeSection("1", "1", { name: "Section One", sectionOrder: 1 });
255+
const sectionTwo = makeSection("1", "2", { name: "Section Two", sectionOrder: 2 });
251256
const sectionThree = makeSection("2", "3", {
252257
name: "Section Three",
253-
order: 2,
258+
sectionOrder: 2,
254259
});
255260

256261
const testcases: TestCase[] = [

plugin/src/data/transformations/grouping.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function groupByProject(tasks: Task[]): GroupedTasks[] {
5656
groups.sort((a, b) => {
5757
const aProject = a[0];
5858
const bProject = b[0];
59-
return aProject.order - bProject.order;
59+
return aProject.childOrder - bProject.childOrder;
6060
});
6161

6262
return groups.map(([project, tasks]) => {
@@ -97,7 +97,7 @@ function groupBySection(tasks: Task[]): GroupedTasks[] {
9797
const bKey: SectionPartitionKey = JSON.parse(b[0]);
9898

9999
// First compare by project
100-
const projectOrderDiff = aKey.project.order - bKey.project.order;
100+
const projectOrderDiff = aKey.project.childOrder - bKey.project.childOrder;
101101
if (projectOrderDiff !== 0) {
102102
return projectOrderDiff;
103103
}
@@ -115,7 +115,7 @@ function groupBySection(tasks: Task[]): GroupedTasks[] {
115115
return 1;
116116
}
117117

118-
return aKey.section.order - bKey.section.order;
118+
return aKey.section.sectionOrder - bKey.section.sectionOrder;
119119
});
120120

121121
return groups.map(([key, tasks]) => {

plugin/src/data/transformations/relationships.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ function makeTask(id: string, opts?: Partial<Task>): Task {
1717
project: opts?.project ?? {
1818
id: "foobar",
1919
name: "Foobar",
20-
order: 1,
20+
childOrder: 1,
2121
parentId: null,
2222
inboxProject: false,
2323
color: "grey",
24+
isDeleted: false,
25+
isArchived: false,
2426
},
2527
section: opts?.section,
2628

0 commit comments

Comments
 (0)