diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ab69a..075900f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +> Note: the style changes in this release mean that you may need to tweak any custom CSS or themes. The changes are based on the default theme. + ### ✨ Features - Added the option to wrap page links in parenthesis when creating tasks with the command. You may find this useful if you primarily use Todoist on mobile where links are less obvious. Thanks to [@ThDag](https://github.com/ThDag) for the contribution! - You can now use the `{{filename}}` placeholder in the filter property. This will be replaced with the name of the file where the query is defined. - For example, if the full file path is `My Vault/Notes/How to Take Smart Notes.md` then the replaced file name will be `How to Take Smart Notes`. -- Create "Add item" button - open same modal window of task creation. +- Create "Add item" button to rendered queries. This will open the task creatio modal. ### 🔁 Changes - You can now toggle whether or not task descriptions are rendered for each task. -- Change the style of the sync button to match the new Obsidian style of the "edit" button. -- Fix intent level to match std markdown levels - to have consistent style. +- Change the style of the sync button to match the Obsidian style of the "edit" codeblock button. +- Fix intent level to match standard markdown levels in order to have consistent style. +- Aligned the grouping behaviour with Todoist's when grouping by project. This will be expanded on in a future release. ## [1.11.1] - 2023-04-09 @@ -105,7 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🐛 Bug Fixes -- Right-click context menu now works with the latest Obsidian version. +- Right-click context menu now works with the latest Obsidian version. ## [1.7.0] - 2021-01-24 diff --git a/flake.nix b/flake.nix index 5bfa696..15a3759 100644 --- a/flake.nix +++ b/flake.nix @@ -6,29 +6,27 @@ }; nixConfig = { - extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; + extra-trusted-public-keys = + "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; extra-substituters = "https://devenv.cachix.org"; }; - outputs = { self, nixpkgs, devenv, systems, ... } @ inputs: - let - forEachSystem = nixpkgs.lib.genAttrs (import systems); - in - { - devShells = forEachSystem - (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in - { - default = devenv.lib.mkShell { - inherit inputs pkgs; - modules = [ - { - packages = [ pkgs.nodejs pkgs.nodePackages.typescript-language-server]; - } + outputs = { self, nixpkgs, devenv, systems, ... }@inputs: + let forEachSystem = nixpkgs.lib.genAttrs (import systems); + in { + devShells = forEachSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + default = devenv.lib.mkShell { + inherit inputs pkgs; + modules = [{ + packages = [ + pkgs.nodejs + pkgs.nodePackages.typescript-language-server + pkgs.nodePackages.svelte-language-server ]; - }; - }); + }]; + }; + }); }; } diff --git a/package.json b/package.json index 447877d..dec48bd 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,25 @@ { - "name": "todoist-plugin", + "name": "obsidian-todoist-plugin", "version": "1.11.1", "description": "A Todoist plugin for Obsidian", "main": "src/index.js", "scripts": { - "dev": "npm run build && cp -R dist/* test-vault/.obsidian/plugins/todoist-sync-plugin/", + "dev": "npm run build && cp -R dist/* ../../test-vault/.obsidian/plugins/todoist-sync-plugin/", "build": "svelte-check && rollup -c", - "test": "mocha -r ts-node/register src/**/*.test.ts", + "test": "npx mocha -r ts-node/register 'src/**/*.test.ts'", "format": "prettier --write src/**/*", "lint": "prettier --check src/**/*" }, "author": "Jamie Brynes", "license": "ISC", "dependencies": { - "@types/node": "^18.11.17", + "camelize": "^1.0.1", "moment": "^2.29.4", "obsidian": "0.15", + "snakeize": "^0.1.0", "svelte": "^3.55.0", "svelte-select": "^5.0.1", + "todoist-api": "^1.0.0", "tslib": "^2.4.1", "yaml": "^2.1.3" }, @@ -28,6 +30,7 @@ "@tsconfig/svelte": "^3.0.0", "@types/chai": "^4.3.4", "@types/mocha": "^10.0.1", + "@types/node": "^18.11.17", "chai": "^4.3.7", "mocha": "^10.2.0", "prettier": "^2.8.1", @@ -41,4 +44,4 @@ "ts-node": "^10.9.1", "typescript": "^4.9.4" } -} +} \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts deleted file mode 100644 index 6c72a96..0000000 --- a/src/api/api.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { writable } from "svelte/store"; -import type { Writable } from "svelte/store"; -import debug from "../log"; -import type { - ITaskRaw, - IProjectRaw, - ISectionRaw, - ILabelRaw, -} from "./raw_models"; -import { Task, Project } from "./models"; -import type { ID, ProjectID, SectionID, LabelID } from "./models"; -import { ExtendedMap } from "../utils"; -import { Result } from "../result"; -import { - requestUrl, - type RequestUrlParam, - type RequestUrlResponse, -} from "obsidian"; - -export interface ITodoistMetadata { - projects: ExtendedMap; - sections: ExtendedMap; - labels: ExtendedMap; -} - -export interface ICreateTaskOptions { - description: string; - priority: number; - project_id?: ProjectID; - section_id?: SectionID; - due_date?: string; - labels?: string[]; -} - -export class TodoistApi { - public metadata: Writable; - public metadataInstance: ITodoistMetadata; - private token: string; - - constructor(token: string) { - this.token = token; - this.metadataInstance = { - projects: new ExtendedMap(), - sections: new ExtendedMap(), - labels: new ExtendedMap(), - }; - - this.metadata = writable(this.metadataInstance); - this.metadata.subscribe((value) => (this.metadataInstance = value)); - } - - async createTask( - content: string, - options?: ICreateTaskOptions - ): Promise> { - const data = { content: content, ...(options ?? {}) }; - - try { - const result = await this.makeRequest({ - method: "POST", - path: "/tasks", - jsonBody: data, - }); - - if (result.status == 200) { - return Result.Ok({}); - } else { - return Result.Err(new Error("Failed to create task")); - } - } catch (e) { - return Result.Err(e); - } - } - - async getTasks(filter?: string): Promise> { - let url = "/tasks"; - - if (filter) { - url += `?filter=${encodeURIComponent(filter)}`; - } - try { - const result = await this.makeRequest({ - method: "GET", - path: url, - }); - if (result.status == 200) { - const tasks = result.json as ITaskRaw[]; - const tree = Task.buildTree(tasks); - - debug({ - msg: "Built task tree", - context: tree, - }); - - return Result.Ok(tree); - } else { - return Result.Err(new Error(result.text)); - } - } catch (e) { - return Result.Err(e); - } - } - - async getTasksGroupedByProject( - filter?: string - ): Promise> { - let url = "/tasks"; - - if (filter) { - url += `?filter=${encodeURIComponent(filter)}`; - } - try { - const result = await this.makeRequest({ - method: "GET", - path: url, - }); - if (result.status == 200) { - // Force the metadata to update. - const metadataResult = await this.fetchMetadata(); - - if (metadataResult.isErr()) { - return Result.Err(metadataResult.unwrapErr()); - } - - const tasks = result.json as ITaskRaw[]; - const tree = Project.buildProjectTree(tasks, this.metadataInstance); - - debug({ - msg: "Built project tree", - context: tree, - }); - - return Result.Ok(tree); - } else { - return Result.Err(new Error(result.text)); - } - } catch (e) { - return Result.Err(e); - } - } - - async closeTask(id: ID): Promise { - const result = await this.makeRequest({ - method: "POST", - path: `/tasks/${id}/close`, - }); - return result.status == 204; - } - - async fetchMetadata(): Promise> { - const projectResult = await this.getProjects(); - const sectionResult = await this.getSections(); - const labelResult = await this.getLabels(); - - const merged = Result.All(projectResult, sectionResult, labelResult); - - if (merged.isErr()) { - return merged.intoErr(); - } - - const [projects, sections, labels] = merged.unwrap(); - - this.metadata.update((metadata) => { - metadata.projects.clear(); - metadata.sections.clear(); - metadata.labels.clear(); - projects.forEach((prj) => metadata.projects.set(prj.id, prj)); - sections.forEach((sect) => metadata.sections.set(sect.id, sect)); - labels.forEach((label) => metadata.labels.set(label.id, label.name)); - return metadata; - }); - - return Result.Ok({}); - } - - private async getProjects(): Promise> { - try { - const result = await this.makeRequest({ - method: "GET", - path: "/projects", - }); - return result.status == 200 - ? Result.Ok(result.json as IProjectRaw[]) - : Result.Err(new Error(result.text)); - } catch (e) { - return Result.Err(e); - } - } - - private async getSections(): Promise> { - try { - const result = await this.makeRequest({ - method: "GET", - path: "/sections", - }); - return result.status == 200 - ? Result.Ok(result.json as ISectionRaw[]) - : Result.Err(new Error(result.text)); - } catch (e) { - return Result.Err(e); - } - } - - private async getLabels(): Promise> { - try { - const result = await this.makeRequest({ - method: "GET", - path: "/labels", - }); - return result.status == 200 - ? Result.Ok(result.json as ILabelRaw[]) - : Result.Err(new Error(result.text)); - } catch (e) { - return Result.Err(e); - } - } - - private async makeRequest( - params: RequestParams - ): Promise { - const requestParams: RequestUrlParam = { - url: `https://api.todoist.com/rest/v2${params.path}`, - method: params.method, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }; - - debug(`[Todoist API]: ${requestParams.method} ${requestParams.url}`); - - if (params.jsonBody) { - requestParams.body = JSON.stringify(params.jsonBody); - requestParams.headers = { - ...requestParams.headers, - ...{ - "Content-Type": "application/json", - }, - }; - } - - const response = await requestUrl(requestParams); - - if (response.status >= 400) { - console.error( - `[Todoist API]: ${requestParams.method} ${requestParams.url} returned error '[${response.status}]: ${response.text}` - ); - } - - return response; - } -} - -interface RequestParams { - method: "GET" | "POST"; - path: string; - jsonBody?: any; -} diff --git a/src/api/domain/dueDate.test.ts b/src/api/domain/dueDate.test.ts new file mode 100644 index 0000000..2ac7938 --- /dev/null +++ b/src/api/domain/dueDate.test.ts @@ -0,0 +1,77 @@ +import { assert } from "chai"; +import "mocha"; +import { getDueDateInfo, type DueDate, type DueDateInfo } from "./dueDate"; +import moment from "moment"; + +// TODO: Fix tests to not rely on actual 'current' time. +describe("getDueDateInfo", () => { + type TestCase = { + description: string, + input: DueDate | undefined, + expected: DueDateInfo + }; + + const testcases: TestCase[] = [ + { + description: "should return false for everything if undefined", + input: undefined, + expected: { + hasDate: false, + hasTime: false, + isOverdue: false, + isToday: false, + } + }, + { + description: "should have hasDate if there is a date", + input: { + recurring: false, + date: "2030-12-31" + }, + expected: { + hasDate: true, + hasTime: false, + isOverdue: false, + isToday: false, + m: moment("2030-12-31"), + }, + }, + { + description: "should have hasTime if there is time", + input: { + recurring: false, + date: "", + datetime: "2030-12-31T12:50:00", + }, + expected: { + hasDate: true, + hasTime: true, + isOverdue: false, + isToday: false, + m: moment("2030-12-31T12:50:00") + } + }, + { + description: "should have isOverdue if is before now", + input: { + recurring: false, + date: "2015-07-12", + }, + expected: { + hasDate: true, + hasTime: false, + isOverdue: true, + isToday: false, + m: moment("2015-07-12") + } + } + // TODO: Add test for 'isToday' + ]; + + for (const tc of testcases) { + it(tc.description, () => { + const actual = getDueDateInfo(tc.input); + assert.deepEqual(actual, tc.expected); + }); + } +}); diff --git a/src/api/domain/dueDate.ts b/src/api/domain/dueDate.ts new file mode 100644 index 0000000..30f962f --- /dev/null +++ b/src/api/domain/dueDate.ts @@ -0,0 +1,44 @@ +import moment from "moment"; + +export type DueDate = { + recurring: boolean, + date: string, + datetime?: string, +}; + +export type DueDateInfo = { + hasDate: boolean, + hasTime: boolean, + + isOverdue: boolean, + isToday: boolean, + + m?: moment.Moment, +} + +export function getDueDateInfo(dueDate: DueDate | undefined): DueDateInfo { + if (dueDate === undefined) { + return { + hasDate: false, + hasTime: false, + + isOverdue: false, + isToday: false, + }; + } + + const hasTime = dueDate.datetime !== undefined; + const date = moment(dueDate.datetime ?? dueDate.date); + + const isToday = date.isSame(new Date(), "day"); + const isOverdue = hasTime ? date.isBefore() : date.clone().add(1, "day").isBefore() + + + return { + hasDate: true, + hasTime: hasTime, + isToday: isToday, + isOverdue: isOverdue, + m: date, + }; +} diff --git a/src/api/domain/label.ts b/src/api/domain/label.ts new file mode 100644 index 0000000..9f72d4f --- /dev/null +++ b/src/api/domain/label.ts @@ -0,0 +1,6 @@ +export type LabelId = string; + +export type Label = { + id: LabelId, + name: string, +} \ No newline at end of file diff --git a/src/api/domain/project.ts b/src/api/domain/project.ts new file mode 100644 index 0000000..e743325 --- /dev/null +++ b/src/api/domain/project.ts @@ -0,0 +1,8 @@ +export type ProjectId = string; + +export type Project = { + id: ProjectId, + parentId: ProjectId | null, + name: string, + order: number, +} \ No newline at end of file diff --git a/src/api/domain/section.ts b/src/api/domain/section.ts new file mode 100644 index 0000000..87faa15 --- /dev/null +++ b/src/api/domain/section.ts @@ -0,0 +1,10 @@ +import type { ProjectId } from "./project"; + +export type SectionId = string; + +export type Section = { + id: SectionId, + projectId: ProjectId, + name: string, + order: number, +} \ No newline at end of file diff --git a/src/api/domain/task.ts b/src/api/domain/task.ts new file mode 100644 index 0000000..84803ff --- /dev/null +++ b/src/api/domain/task.ts @@ -0,0 +1,35 @@ +import moment from "moment"; +import type { DueDate } from "./dueDate"; +import type { ProjectId } from "./project"; +import type { SectionId } from "./section"; + +export type TaskId = string; + +export type Task = { + id: TaskId, + + content: string, + description: string, + + projectId: ProjectId, + sectionId: SectionId | null, + parentId: TaskId | null, + + labels: string[], + priority: Priority, + + due: DueDate | null, + + order: number, +}; + +export type Priority = 1 | 2 | 3 | 4; + +export type CreateTaskParams = { + description?: string; + priority?: number; + projectId?: ProjectId, + sectionId?: SectionId, + dueDate?: string, + labels?: string[], +}; diff --git a/src/api/fetcher.ts b/src/api/fetcher.ts new file mode 100644 index 0000000..36745e6 --- /dev/null +++ b/src/api/fetcher.ts @@ -0,0 +1,15 @@ +export interface WebFetcher { + fetch(params: RequestParams): Promise, +} + +export type RequestParams = { + url: string, + method: string, + headers?: Record, + body?: string, +}; + +export type WebResponse = { + statusCode: number, + body: string, +} \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..5ea9813 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,84 @@ +import type { Label } from "./domain/label"; +import type { Project } from "./domain/project"; +import type { Section } from "./domain/section"; +import type { CreateTaskParams, Task, TaskId } from "./domain/task"; +import type { RequestParams, WebFetcher, WebResponse } from "./fetcher"; +import camelize from "camelize"; +import snakeize from "snakeize"; + +export class TodoistApiClient { + private token: string; + private fetcher: WebFetcher; + + constructor(token: string, fetcher: WebFetcher) { + this.token = token; + this.fetcher = fetcher; + } + + public async getTasks(filter?: string): Promise { + let path = "/tasks"; + + if (filter !== undefined) { + path += `?filter=${encodeURIComponent(filter)}`; + } + + const response = await this.do(path, "GET"); + + return camelize(JSON.parse(response.body)) as Task[]; + } + + public async createTask(content: string, options?: CreateTaskParams): Promise { + const body = snakeize({ content: content, ...(options ?? {}) }); + await this.do("/tasks", "POST", body); + } + + public async closeTask(id: TaskId): Promise { + await this.do(`/tasks/${id}/close`, "POST"); + } + + public async getProjects(): Promise { + const response = await this.do("/projects", "GET"); + return camelize(JSON.parse(response.body)) as Project[]; + } + + public async getSections(): Promise { + const response = await this.do("/sections", "GET"); + return camelize(JSON.parse(response.body)) as Section[]; + } + + public async getLabels(): Promise { + const response = await this.do("/labels", "GET"); + return camelize(JSON.parse(response.body)) as Label[]; + } + + private async do(path: string, method: string, json?: object): Promise { + const params: RequestParams = { + url: `https://api.todoist.com/rest/v2${path}`, + method: method, + headers: { + "Authorization": `Bearer ${this.token}`, + }, + }; + + if (json !== undefined) { + params.body = JSON.stringify(json); + params.headers["Content-Type"] = "application/json"; + } + + const response = await this.fetcher.fetch(params); + + if (response.statusCode >= 400) { + throw new TodoistApiError(params, response); + } + + return response; + } +} + +class TodoistApiError extends Error { + constructor(request: RequestParams, response: WebResponse) { + const message = `[${request.method}] ${request.url} returned '${response.statusCode}: ${response.body}`; + super(message) + } +} + diff --git a/src/api/models.ts b/src/api/models.ts deleted file mode 100644 index a56c101..0000000 --- a/src/api/models.ts +++ /dev/null @@ -1,331 +0,0 @@ -import moment from "moment"; -import type { Moment, CalendarSpec } from "moment"; -import { UnknownProject, UnknownSection } from "./raw_models"; -import type { ITaskRaw, IProjectRaw, ISectionRaw } from "./raw_models"; -import type { ITodoistMetadata } from "./api"; -import { ExtendedMap } from "../utils"; - -export type ID = string; -export type ProjectID = string; -export type SectionID = string; -export type LabelID = string; - -export class Task { - public id: ID; - public priority: number; - public content: string; - public description: string; - public order: number; - public projectID: ProjectID; - public sectionID?: SectionID; - public labels: string[]; - - public parent?: Task; - public children: Task[]; - - public date?: string; - public hasTime?: boolean; - public rawDatetime?: Moment; - - private static dateOnlyCalendarSpec: CalendarSpec = { - sameDay: "[Today]", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "[Last] dddd", - sameElse: "MMM Do", - }; - - constructor(raw: ITaskRaw) { - this.id = raw.id; - this.priority = raw.priority; - this.content = raw.content; - this.description = raw.description; - this.order = raw.order; - this.projectID = raw.project_id; - this.sectionID = raw.section_id != null ? raw.section_id : null; - this.labels = raw.labels; - - this.children = []; - - if (raw.due) { - if (raw.due.datetime) { - this.hasTime = true; - this.rawDatetime = moment(raw.due.datetime); - this.date = this.rawDatetime.calendar(); - } else { - this.hasTime = false; - this.rawDatetime = moment(raw.due.date); - this.date = this.rawDatetime.calendar(Task.dateOnlyCalendarSpec); - } - } - } - - public count(): number { - return 1 + this.children.reduce((sum, task) => sum + task.count(), 0); - } - - public isOverdue(): boolean { - if (!this.rawDatetime) { - return false; - } - - if (this.hasTime) { - return this.rawDatetime.isBefore(); - } - - return this.rawDatetime.clone().add(1, "day").isBefore(); - } - - public isToday(): boolean { - if (!this.rawDatetime) { - return false; - } - - return this.rawDatetime.isSame(new Date(), "day"); - } - - public compareTo(other: Task, sorting_options: string[]): number { - /* Compares the dates of this to 'other'. Returns : - * 1 if this is after the other - * 0 if this is equal to the other. - * -1 if this is before the 'other' - */ - const compareDate = () => { - // We want to sort using the following criteria: - // 1. Any items without a datetime always are sorted after those with. - // 2. Any items on the same day without time always are sorted after those with. - if (this.rawDatetime && !other.rawDatetime) { - return -1; - } else if (!this.rawDatetime && other.rawDatetime) { - return 1; - } else if (!this.rawDatetime && !other.rawDatetime) { - return 0; - } - - // Now compare dates. - if (this.rawDatetime.isAfter(other.rawDatetime, "day")) { - return 1; - } else if (this.rawDatetime.isBefore(other.rawDatetime, "day")) { - return -1; - } - - // We are the same day, lets look at time. - if (this.hasTime && !other.hasTime) { - return -1; - } else if (!this.hasTime && other.hasTime) { - return 1; - } else if (!this.hasTime && !this.hasTime) { - return 0; - } - - return this.rawDatetime.isBefore(other.rawDatetime) ? -1 : 1; - }; - - const dateComparison = compareDate(); - - for (let sort of sorting_options) { - switch (sort) { - case "priority": - // Higher priority comes first. - const diff = other.priority - this.priority; - if (diff == 0) { - continue; - } - - return diff; - case "date": - case "dateAscending": - if (dateComparison == 0) { - continue; - } - - return dateComparison; - case "dateDescending": - if (dateComparison == 0) { - continue; - } - - return -dateComparison; - } - } - - return this.order - other.order; - } - - static buildTree(tasks: ITaskRaw[]): Task[] { - const mapping = new Map(); - - tasks.forEach((task) => mapping.set(task.id, new Task(task))); - tasks.forEach((task) => { - if (task.parent_id == null || !mapping.has(task.parent_id)) { - return; - } - - const self = mapping.get(task.id); - const parent = mapping.get(task.parent_id); - - self.parent = parent; - parent.children.push(self); - }); - - return Array.from(mapping.values()).filter((task) => task.parent == null); - } -} - -export class Project { - public readonly projectID: ProjectID; - public readonly parentID?: ProjectID; - public readonly order: number; - - public tasks: Task[]; - public subProjects: Project[]; - public sections: Section[]; - - private parent?: Project; - - constructor(raw: IProjectRaw) { - this.projectID = raw.id; - this.parentID = raw.parent_id; - this.order = raw.order; - - this.tasks = []; - this.subProjects = []; - this.sections = []; - } - - public count(): number { - return ( - this.tasks.reduce((sum, task) => sum + task.count(), 0) + - this.subProjects.reduce((sum, prj) => sum + prj.count(), 0) + - this.sections.reduce((sum, section) => sum + section.count(), 0) - ); - } - - private sort() { - this.subProjects = this.subProjects.sort( - (first, second) => first.order - second.order - ); - this.sections = this.sections.sort( - (first, second) => first.order - second.order - ); - } - - static buildProjectTree( - tasks: ITaskRaw[], - metadata: ITodoistMetadata - ): Project[] { - const projects = new ExtendedMap>(); - const sections = new ExtendedMap>(); - - const unknownProject: Intermediate = { - result: new Project(UnknownProject), - tasks: [], - }; - - const unknownSection: Intermediate
= { - result: new Section(UnknownSection), - tasks: [], - }; - - tasks.forEach((task) => { - const project = - projects.get_or_maybe_insert(task.project_id, () => { - const project = metadata.projects.get(task.project_id); - - if (project) { - return { - result: new Project(project), - tasks: [], - }; - } else { - return null; - } - }) ?? unknownProject; - - if (task.section_id != null) { - // The task has an associated section, so we file it under there. - const section = - sections.get_or_maybe_insert(task.section_id, () => { - const section = metadata.sections.get(task.section_id); - - if (section) { - return { - result: new Section(section), - tasks: [], - }; - } else { - return null; - } - }) ?? unknownSection; - - section.tasks.push(task); - return; - } - - project.tasks.push(task); - }); - - if (unknownProject.tasks.length > 0) { - projects.set(unknownProject.result.projectID, unknownProject); - } - - if (unknownSection.tasks.length > 0) { - projects.set(unknownProject.result.projectID, unknownProject); - sections.set(unknownSection.result.sectionID, unknownSection); - } - - // Attach parents for projects. - for (let project of projects.values()) { - project.result.tasks = Task.buildTree(project.tasks); - - if (!project.result.parentID) { - continue; - } - - const parent = projects.get(project.result.parentID); - - if (parent) { - parent.result.subProjects.push(project.result); - project.result.parent = parent.result; - } - } - - // Attach parents for sections. - for (let section of sections.values()) { - section.result.tasks = Task.buildTree(section.tasks); - - const project = projects.get(section.result.projectID); - project.result.sections.push(section.result); - } - - projects.forEach((prj) => prj.result.sort()); - - return Array.from(projects.values()) - .map((prj) => prj.result) - .filter((prj) => prj.parent == null); - } -} - -export class Section { - public readonly sectionID: SectionID; - public readonly projectID: ProjectID; - public readonly order: number; - - public tasks: Task[]; - - constructor(raw: ISectionRaw) { - this.sectionID = raw.id; - this.projectID = raw.project_id; - this.order = raw.order; - } - - public count(): number { - return this.tasks.reduce((sum, task) => sum + task.count(), 0); - } -} - -interface Intermediate { - result: T; - tasks: ITaskRaw[]; -} diff --git a/src/api/raw_models.ts b/src/api/raw_models.ts deleted file mode 100644 index 8036e6b..0000000 --- a/src/api/raw_models.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { ID, ProjectID, SectionID, LabelID } from "./models"; - -export const UnknownProject: IProjectRaw = { - id: "-1", - parent_id: null, - order: -1, - name: "Unknown project", -}; - -export const UnknownSection: ISectionRaw = { - id: "-1", - project_id: "-1", - order: -1, - name: "Unknown section", -}; - -export interface ITaskRaw { - id: ID; - project_id: ProjectID; - section_id: SectionID | null; - /** - * The task has direct reference to the label names, and does not reference the label objects. - * - * That is, if you have a label {id:"123abc", name:"errand"} in the labels dataset, a task with - * that label will have the attribute { label: ["errand"] }. - */ - labels: string[]; - priority: number; - content: string; - description: string; - order: number; - parent_id?: ID | null; - due?: { - recurring: boolean; - date: string | null; - datetime?: string | null; - }; -} - -export interface IProjectRaw { - id: ProjectID; - parent_id?: ProjectID | null; - order: number; - name: string; -} - -export interface ISectionRaw { - id: SectionID; - project_id: ProjectID; - order: number; - name: string; -} - -export interface ILabelRaw { - id: LabelID; - name: string; -} diff --git a/src/contextMenu.ts b/src/contextMenu.ts index e45675f..ce3449c 100644 --- a/src/contextMenu.ts +++ b/src/contextMenu.ts @@ -1,10 +1,11 @@ import { Menu, Notice } from "obsidian"; import type { Point } from "obsidian"; -import type { Task } from "./api/models"; +import type { Task } from "./data/task"; +import type { TaskId } from "./api/domain/task"; interface TaskContext { task: Task; - onClickTask: (task: Task) => Promise; + closeTask: (id: TaskId) => Promise; } export function showTaskContext( @@ -16,7 +17,7 @@ export function showTaskContext( menuItem .setTitle("Complete task") .setIcon("check-small") - .onClick(async () => taskCtx.onClickTask(taskCtx.task)) + .onClick(async () => taskCtx.closeTask(taskCtx.task.id)) ) .addItem((menuItem) => menuItem @@ -32,7 +33,7 @@ export function showTaskContext( .setIcon("popup-open") .onClick(() => openExternal( - `https://todoist.com/app/project/${taskCtx.task.projectID}/task/${taskCtx.task.id}` + `https://todoist.com/app/project/${taskCtx.task.project.id}/task/${taskCtx.task.id}` ) ) ) diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..7ce5ce1 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,119 @@ +import type { TodoistApiClient } from "../api"; +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 { Repository, type RepositoryReader } from "./repository"; +import type { Task } from "./task"; +import type { Task as ApiTask, CreateTaskParams, TaskId } from "../api/domain/task"; +import { SubscriptionManager, type UnsubscribeCallback } from "./subscriptions"; +import { Maybe } from "../utils/maybe"; + +type SubscriptionResult = { type: "success", tasks: Task[] } | { type: "error" }; +type OnSubscriptionChange = (result: SubscriptionResult) => void; +type Refresh = () => Promise; + +type DataAccessor = { + projects: RepositoryReader, + sections: RepositoryReader, + labels: RepositoryReader, +} + +type Actions = { + closeTask: (id: TaskId) => Promise, + createTask: (content: string, params: CreateTaskParams) => Promise, +}; + +export class TodoistAdapter { + public actions: Actions = { + closeTask: async (id) => await this.api.withInner(api => api.closeTask(id)), + createTask: async (content, params) => await this.api.withInner(api => api.createTask(content, params)), + } + + private readonly api: Maybe = Maybe.Empty(); + private readonly projects: Repository; + private readonly sections: Repository; + private readonly labels: Repository; + private readonly subscriptions: SubscriptionManager; + + constructor() { + this.projects = new Repository(() => this.api.withInner(api => api.getProjects())); + this.sections = new Repository(() => this.api.withInner(api => api.getSections())); + this.labels = new Repository(() => this.api.withInner(api => api.getLabels())); + this.subscriptions = new SubscriptionManager(); + } + + public async initialize(api: TodoistApiClient) { + this.api.insert(api); + await this.sync(); + } + + public async sync(): Promise { + if (!this.api.hasValue()) { + return; + } + + await this.projects.sync(); + await this.sections.sync(); + await this.labels.sync(); + + for (const refresh of this.subscriptions.listActive()) { + await refresh(); + } + } + + public data(): DataAccessor { + return { + projects: this.projects, + sections: this.sections, + labels: this.labels, + }; + } + + public subscribe(query: string, callback: OnSubscriptionChange): [UnsubscribeCallback, Refresh] { + const refresh = this.buildRefresher(query, callback); + return [ + this.subscriptions.subscribe(refresh), + refresh + ]; + } + + private buildRefresher(query: string, callback: OnSubscriptionChange): Refresh { + return async () => { + if (!this.api.hasValue()) { + return; + } + try { + const data = await this.api.withInner(api => api.getTasks(query)); + const hydrated = data.map(t => this.hydrate(t)); + callback({ type: "success", tasks: hydrated }); + } + catch (error: any) { + console.error(`Failed to refresh task query: ${error}`); + callback({ type: "error" }); + } + }; + } + + private hydrate(apiTask: ApiTask): Task { + const project = this.projects.byId(apiTask.projectId); + const section = apiTask.sectionId ? this.sections.byId(apiTask.sectionId) : undefined; + + return { + id: apiTask.id, + + content: apiTask.content, + description: apiTask.description, + + project: project, + section: section, + parentId: apiTask.parentId, + + labels: apiTask.labels, + priority: apiTask.priority, + + due: apiTask.due, + order: apiTask.order + }; + } +} + diff --git a/src/data/repository.ts b/src/data/repository.ts new file mode 100644 index 0000000..c6bc2ae --- /dev/null +++ b/src/data/repository.ts @@ -0,0 +1,34 @@ +export interface RepositoryReader { + byId(id: T): U | undefined; + iter(): IterableIterator; +} + +export class Repository implements RepositoryReader { + private readonly data: Map = new Map(); + private readonly fetchData: () => Promise; + + constructor(refreshData: () => Promise) { + this.fetchData = refreshData; + } + + public async sync(): Promise { + try { + const items = await this.fetchData(); + + this.data.clear(); + for (const elem of items) { + this.data.set(elem.id, elem); + } + } catch (error: any) { + console.error(`Failed to update repository: ${error}`); + } + } + + public byId(id: T): U | undefined { + return this.data.get(id); + } + + public iter(): IterableIterator { + return this.data.values(); + } +} diff --git a/src/data/subscriptions.ts b/src/data/subscriptions.ts new file mode 100644 index 0000000..b60c914 --- /dev/null +++ b/src/data/subscriptions.ts @@ -0,0 +1,26 @@ +type SubscriptionId = number; +export type UnsubscribeCallback = () => void; + +export class SubscriptionManager { + private readonly subscriptions: Map = new Map(); + private generator: Generator = subscriptionIdGenerator(); + + public subscribe(value: T): UnsubscribeCallback { + const id = this.generator.next().value; + this.subscriptions.set(id, value); + + return () => this.subscriptions.delete(id); + } + + public listActive(): IterableIterator { + return this.subscriptions.values(); + } +} + +function* subscriptionIdGenerator(): Generator { + let next = 0; + + while (true) { + yield next++; + } +} diff --git a/src/data/task.ts b/src/data/task.ts new file mode 100644 index 0000000..17bca59 --- /dev/null +++ b/src/data/task.ts @@ -0,0 +1,21 @@ +import type { DueDate } from "../api/domain/dueDate"; +import type { Project } from "../api/domain/project"; +import type { Section } from "../api/domain/section"; +import type { Priority, TaskId } from "../api/domain/task"; + +export type Task = { + id: TaskId, + + content: string, + description: string, + + project?: Project, + section?: Section, + parentId?: TaskId, + + labels: string[], + priority: Priority, + + due?: DueDate, + order: number, +}; diff --git a/src/data/transformations.test.ts b/src/data/transformations.test.ts new file mode 100644 index 0000000..7764953 --- /dev/null +++ b/src/data/transformations.test.ts @@ -0,0 +1,406 @@ +import "mocha"; +import type { Task } from "./task"; +import type { Project } from "../api/domain/project"; +import { UnknownProject, groupByProject, type GroupedTasks, type Sort, sortTasks, type TaskTree, buildTaskTree } from "./transformations"; +import { assert } from "chai"; + +function makeTask(id: string, opts?: Partial): Task { + return { + id, + parentId: opts?.parentId, + content: "", + description: "", + labels: [], + priority: opts?.priority ?? 1, + order: opts?.order ?? 0, + + project: opts?.project, + section: opts?.section, + + due: opts?.due + } +} + +function makeProject(name: string, order: number): Project { + return { + id: name, + parentId: null, + name, + order, + }; +} + +describe("groupByProject", () => { + const projectOne = makeProject("Project One", 1); + const projectTwo = makeProject("Project Two", 2); + + type Testcase = { + description: string, + input: Task[], + expected: GroupedTasks[], + }; + + const testcases: Testcase[] = [ + { + description: "should return empty array if no tasks", + input: [], + expected: [], + }, + { + description: "should collect tasks into distinct projects", + input: [ + makeTask("a", { project: projectOne }), + makeTask("b", { project: projectOne }), + makeTask("c", { project: projectTwo }), + makeTask("d", { project: projectTwo }), + ], + expected: [ + { + project: projectOne, + tasks: [ + makeTask("a", { project: projectOne }), + makeTask("b", { project: projectOne }), + ] + }, + { + project: projectTwo, + tasks: [ + makeTask("c", { project: projectTwo }), + makeTask("d", { project: projectTwo }), + ] + }, + ] + }, + { + description: "should use unknown project if project is undefined", + input: [ + makeTask("a"), + makeTask("b"), + makeTask("c", { project: projectOne }), + ], + expected: [ + { + project: projectOne, + tasks: [makeTask("c", { project: projectOne })] + }, + { + project: UnknownProject, + tasks: [makeTask("a"), makeTask("b")] + } + ], + } + ]; + + for (const tc of testcases) { + it(tc.description, () => { + const grouped = groupByProject(tc.input); + // Sort to make comparisons easier to reason about + grouped.sort((a, b) => a.project.order - b.project.order); + + assert.deepEqual(grouped, tc.expected); + }); + } +}); + +describe("sortTasks", () => { + type Testcase = { + description: string, + input: Task[], + sortingOpts: Sort[], + expectedOutput: Task[], + }; + + const testcases: Testcase[] = [ + { + description: "should not error for empty input", + input: [], + sortingOpts: ["priority"], + expectedOutput: [], + }, + { + description: "can sort by priority", + input: [ + makeTask("a", { priority: 2 }), + makeTask("b", { priority: 1 }), + makeTask("c", { priority: 4 }), + ], + sortingOpts: ["priority"], + expectedOutput: [ + makeTask("c", { priority: 4 }), + makeTask("a", { priority: 2 }), + makeTask("b", { priority: 1 }), + ] + }, + { + description: "can sort by priority descending", + input: [ + makeTask("a", { priority: 2 }), + makeTask("b", { priority: 1 }), + makeTask("c", { priority: 4 }), + ], + sortingOpts: ["priorityDescending"], + expectedOutput: [ + makeTask("b", { priority: 1 }), + makeTask("a", { priority: 2 }), + makeTask("c", { priority: 4 }), + ] + }, + { + description: "can sort by Todoist order", + input: [ + makeTask("a", { order: 2 }), + makeTask("b", { order: 3 }), + makeTask("c", { order: 1 }), + ], + sortingOpts: ["order"], + expectedOutput: [ + makeTask("c", { order: 1 }), + makeTask("a", { order: 2 }), + makeTask("b", { order: 3 }), + ], + }, + { + description: "can sort by date (ascending)", + input: [ + makeTask("a"), + makeTask("b", { + due: { + recurring: false, + date: "2020-03-20" + }, + }), + makeTask("c", { + due: { + recurring: false, + date: "2020-03-15" + }, + }), + makeTask("d", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T15:00:00" + }, + }), + makeTask("e", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T13:00:00" + }, + }), + ], + sortingOpts: ["date"], + expectedOutput: [ + makeTask("e", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T13:00:00" + }, + }), + makeTask("d", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T15:00:00" + }, + }), + makeTask("c", { + due: { + recurring: false, + date: "2020-03-15" + }, + }), + makeTask("b", { + due: { + recurring: false, + date: "2020-03-20" + }, + }), + makeTask("a"), + ], + }, + { + description: "can sort by date (descending)", + input: [ + makeTask("e", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T13:00:00" + }, + }), + makeTask("d", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T15:00:00" + }, + }), + makeTask("c", { + due: { + recurring: false, + date: "2020-03-15" + }, + }), + makeTask("b", { + due: { + recurring: false, + date: "2020-03-20" + }, + }), + makeTask("a"), + ], + sortingOpts: ["dateDescending"], + expectedOutput: [ + makeTask("a"), + makeTask("b", { + due: { + recurring: false, + date: "2020-03-20" + }, + }), + makeTask("c", { + due: { + recurring: false, + date: "2020-03-15" + }, + }), + makeTask("d", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T15:00:00" + }, + }), + makeTask("e", { + due: { + recurring: false, + date: "2020-03-20", + datetime: "2020-03-15T13:00:00" + }, + }), + ], + }, + { + description: "will sort using specified parameters in order", + input: [ + makeTask("a", { priority: 2, due: { recurring: false, date: "2020-03-20" } }), + makeTask("b", { priority: 2, due: { recurring: false, date: "2020-03-19" } }), + makeTask("c", { priority: 3, due: { recurring: false, date: "2020-03-25" } }), + ], + sortingOpts: ["priority", "date"], + expectedOutput: [ + makeTask("c", { priority: 3, due: { recurring: false, date: "2020-03-25" } }), + makeTask("b", { priority: 2, due: { recurring: false, date: "2020-03-19" } }), + makeTask("a", { priority: 2, due: { recurring: false, date: "2020-03-20" } }), + ], + } + ]; + + for (const tc of testcases) { + it(tc.description, () => { + const cloned = [...tc.input]; + sortTasks(cloned, tc.sortingOpts); + + assert.deepEqual(cloned, tc.expectedOutput); + }); + } +}); + +describe("buildTaskTree", () => { + type Testcase = { + description: string, + input: Task[], + output: TaskTree[], + } + + const testcases: Testcase[] = [ + { + description: "tasks without children should have no children", + input: [ + makeTask("a"), + makeTask("b"), + makeTask("c"), + ], + output: [ + { children: [], ...makeTask("a") }, + { children: [], ...makeTask("b") }, + { children: [], ...makeTask("c") }, + ], + }, + { + description: "tasks with children should be parented", + input: [ + makeTask("a"), + makeTask("b", { parentId: "a" }), + makeTask("c"), + ], + output: [ + { + ...makeTask("a"), + children: [ + { + ...makeTask("b", { parentId: "a" }), + children: [], + } + ], + + }, + { + ...makeTask("c"), + children: [], + } + ] + }, + { + description: "tasks with unknown parent ID are part of root", + input: [ + makeTask("b"), + makeTask("a", { parentId: "c" }), + ], + output: [ + { + ...makeTask("b"), + children: [], + }, + { + ...makeTask("a", { parentId: "c" }), + children: [], + } + ] + }, + { + description: "tasks will be nested deeply", + input: [ + makeTask("a", { parentId: "c" }), + makeTask("b", { parentId: "a" }), + makeTask("c"), + ], + output: [ + { + ...makeTask("c"), + children: [ + { + ...makeTask("a", { parentId: "c" }), + children: [ + { + ...makeTask("b", { parentId: "a" }), + children: [], + } + ] + } + ] + } + ] + } + ]; + + for (const tc of testcases) { + it(tc.description, () => { + const trees = buildTaskTree(tc.input); + assert.deepEqual(trees, tc.output); + }); + } +}); \ No newline at end of file diff --git a/src/data/transformations.ts b/src/data/transformations.ts new file mode 100644 index 0000000..0dd1e78 --- /dev/null +++ b/src/data/transformations.ts @@ -0,0 +1,142 @@ +import { getDueDateInfo } from "../api/domain/dueDate"; +import type { Project } from "../api/domain/project"; +import type { TaskId } from "../api/domain/task"; +import type { Task } from "./task"; + +export type GroupedTasks = { + project: Project, + tasks: Task[], +}; + +export const UnknownProject: Project = { + id: "unknown-project-fake", + parentId: null, + name: "Unknown Project", + order: Number.MAX_SAFE_INTEGER, +}; + +export function groupByProject(tasks: Task[]): GroupedTasks[] { + const projects = new Map(); + + for (const task of tasks) { + const project = task.project ?? UnknownProject; + + if (!projects.has(project)) { + projects.set(project, []); + } + + const tasks = projects.get(project); + tasks.push(task); + } + + return Array.from(projects.entries()).map(([project, tasks]) => { return { project: project, tasks: tasks }; }) +} + +export type Sort = + "priority" | "priorityAscending" + | "priorityDescending" + | "date" | "dateAscending" + | "dateDescending" + | "order"; + +export function sortTasks(tasks: T[], sort: Sort[]) { + tasks.sort((first, second) => { + for (const sorting of sort) { + const cmp = compareTask(first, second, sorting); + if (cmp == 0) { + continue; + } + + return cmp; + } + + return 0; + }) +} + + +// Result of "LT zero" means that self is before other, +// Result of '0' means that they are equal +// Result of "GT zero" means that self is after other +function compareTask(self: T, other: T, sorting: Sort): number { + switch (sorting) { + case "priority": + case "priorityAscending": + // Note that priority in the API is reversed to that of in the app. + return other.priority - self.priority; + case "priorityDescending": + return self.priority - other.priority; + case "date": + case "dateAscending": + return compareTaskDate(self, other); + case "dateDescending": + return -compareTaskDate(self, other); + case "order": + return self.order - other.order; + default: + throw new Error(`Unexpected sorting type: '${sorting}'`) + } +} + +function compareTaskDate(self: T, other: T): number { + // We will sort items using the following algorithm: + // 1. Any items without a due date are always after those with. + // 2. Any items on the same day, but without time are always sorted after those with time. + + const selfInfo = getDueDateInfo(self.due); + const otherInfo = getDueDateInfo(other.due); + + // First lets check for presence of due date + if (selfInfo.hasDate && !otherInfo.hasDate) { + return -1; + } else if (!selfInfo.hasDate && otherInfo.hasDate) { + return 1; + } else if (!selfInfo.hasDate && !otherInfo.hasDate) { + return 0; + } + + const selfDate = selfInfo.m; + const otherDate = otherInfo.m; + + // Then lets check if we are the same day, if not + // sort just based on the day. + if (!selfDate.isSame(otherDate, "day")) { + return selfDate.isBefore(otherDate, "day") ? -1 : 1; + } + + if (selfInfo.hasTime && !otherInfo.hasTime) { + return -1; + } else if (!selfInfo.hasTime && otherInfo.hasTime) { + return 1; + } else if (!selfInfo.hasTime && !otherInfo.hasTime) { + return 0; + } + + return selfDate.isBefore(otherDate) ? -1 : 1; +} + + +export type TaskTree = Task & { children: TaskTree[] }; + +// Builds a task tree, preserving the sorting order. +export function buildTaskTree(tasks: Task[]): TaskTree[] { + const mapping = new Map(); + const roots: TaskId[] = []; + + for (const task of tasks) { + mapping.set(task.id, { ...task, children: [] }); + } + + for (const task of tasks) { + if (task.parentId == undefined || !mapping.has(task.parentId)) { + roots.push(task.id); + continue; + } + + const parent = mapping.get(task.parentId); + const child = mapping.get(task.id); + parent.children.push(child) + } + + return roots.map(id => mapping.get(id)); +} diff --git a/src/index.ts b/src/index.ts index b891198..61a7457 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,26 @@ -import { SettingsInstance, SettingsTab } from "./settings"; +import { settings, SettingsTab } from "./settings"; import type { ISettings } from "./settings"; -import { TodoistApi } from "./api/api"; import debug from "./log"; -import { App, Plugin } from "obsidian"; +import { App, Plugin, requestUrl } from "obsidian"; import type { PluginManifest } from "obsidian"; import TodoistApiTokenModal from "./modals/enterToken/enterTokenModal"; import CreateTaskModal from "./modals/createTask/createTaskModal"; import { QueryInjector } from "./query/injector"; import { getTokenPath } from "./token"; +import { TodoistAdapter } from "./data"; +import { TodoistApiClient } from "./api"; +import type { RequestParams, WebFetcher, WebResponse } from "./api/fetcher"; export default class TodoistPlugin extends Plugin { - public options: ISettings; + public options: ISettings | null; - private api: TodoistApi; - - private readonly queryInjector: QueryInjector; + private todoistAdapter: TodoistAdapter = new TodoistAdapter(); constructor(app: App, pluginManifest: PluginManifest) { super(app, pluginManifest); - this.options = null; - this.api = null; - SettingsInstance.subscribe((value) => { + settings.subscribe((value) => { debug({ msg: "Settings changed", context: value, @@ -30,28 +28,19 @@ export default class TodoistPlugin extends Plugin { this.options = value; }); - - this.queryInjector = new QueryInjector(app); } async onload() { - this.registerMarkdownCodeBlockProcessor("todoist", - this.queryInjector.onNewBlock.bind(this.queryInjector) - ); + const queryInjector = new QueryInjector(this.todoistAdapter) + this.registerMarkdownCodeBlockProcessor("todoist", queryInjector.onNewBlock.bind(queryInjector)); this.addSettingTab(new SettingsTab(this.app, this)); this.addCommand({ - id: "todoist-refresh-metadata", - name: "Refresh Metadata", + id: "todoist-sync", + name: "Sync with Todoist", callback: async () => { - if (this.api != null) { - debug("Refreshing metadata"); - const result = await this.api.fetchMetadata(); - - if (result.isErr()) { - console.error(result.unwrapErr()); - } - } + debug("Syncing with Todoist API"); + this.todoistAdapter.sync(); }, }); @@ -61,7 +50,7 @@ export default class TodoistPlugin extends Plugin { callback: () => { new CreateTaskModal( this.app, - this.api, + this.todoistAdapter, this.options, false ); @@ -74,48 +63,46 @@ export default class TodoistPlugin extends Plugin { callback: () => { new CreateTaskModal( this.app, - this.api, + this.todoistAdapter, this.options, true ); }, }); + await this.loadOptions(); + + const token = await this.getToken(); + if (token.length === 0) { + alert( + "Provided token was empty, please enter it in the settings and restart Obsidian or reload plugin." + ); + return; + } + const api = new TodoistApiClient(token, new ObsidianFetcher()); + await this.todoistAdapter.initialize(api) + } + + private async getToken(): Promise { const tokenPath = getTokenPath(app.vault); + try { const token = await this.app.vault.adapter.read(tokenPath); - this.api = new TodoistApi(token); + return token; } catch (e) { const tokenModal = new TodoistApiTokenModal(this.app); await tokenModal.waitForClose; const token = tokenModal.token; - if (token.length == 0) { - alert( - "Provided token was empty, please enter it in the settings and restart Obsidian." - ); - return; - } - await this.app.vault.adapter.write(tokenPath, token); - this.api = new TodoistApi(token); - } - - this.queryInjector.setApi(this.api); - - const result = await this.api.fetchMetadata(); - - if (result.isErr()) { - console.error(result.unwrapErr()); + return token; } - - await this.loadOptions(); } async loadOptions(): Promise { const options = await this.loadData(); - SettingsInstance.update((old) => { + settings.update((old) => { return { ...old, ...(options || {}), @@ -126,10 +113,27 @@ export default class TodoistPlugin extends Plugin { } async writeOptions(changeOpts: (settings: ISettings) => void): Promise { - SettingsInstance.update((old) => { + settings.update((old) => { changeOpts(old); return old; }); await this.saveData(this.options); } } + +class ObsidianFetcher implements WebFetcher { + public async fetch(params: RequestParams): Promise { + const response = await requestUrl({ + url: params.url, + method: params.method, + body: params.body, + headers: params.headers, + }); + + return { + statusCode: response.status, + body: response.text, + } + } + +} diff --git a/src/log.ts b/src/log.ts index 140c47f..78f9ab0 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,11 +1,11 @@ import type { ISettings } from "./settings"; -import { SettingsInstance } from "./settings"; +import { settings } from "./settings"; -let settings: ISettings = null; -SettingsInstance.subscribe((value) => (settings = value)); +let _settings: ISettings | undefined = undefined; +settings.subscribe((update) => _settings = update); export default function debug(log: string | LogMessage) { - if (!settings.debugLogging) { + if (!_settings.debugLogging) { return; } diff --git a/src/modals/createTask/CreateTaskModalContent.svelte b/src/modals/createTask/CreateTaskModalContent.svelte index 7c89d14..0c83ef5 100644 --- a/src/modals/createTask/CreateTaskModalContent.svelte +++ b/src/modals/createTask/CreateTaskModalContent.svelte @@ -2,18 +2,15 @@ import type { Moment } from "moment"; import { Notice } from "obsidian"; import { onMount, tick } from "svelte"; - import type { - ICreateTaskOptions, - ITodoistMetadata, - TodoistApi, - } from "../../api/api"; import DateSelector from "./DateSelector.svelte"; import LabelSelector from "./LabelSelector.svelte"; import PriorityPicker from "./PriorityPicker.svelte"; import ProjectSelector from "./ProjectSelector.svelte"; import type { LabelOption, ProjectOption } from "./types"; + import type { TodoistAdapter } from "../../data"; + import type { CreateTaskParams } from "../../api/domain/task"; - export let api: TodoistApi; + export let todoistAdapter: TodoistAdapter; export let close: () => void; export let value: string; export let initialCursorPosition: number | undefined; @@ -26,9 +23,6 @@ let inputEl: HTMLInputElement; - let metadata: ITodoistMetadata; - api.metadata.subscribe((value) => (metadata = value)); - let isBeingCreated: boolean = false; onMount(async () => { @@ -48,7 +42,7 @@ isBeingCreated = true; - let opts: ICreateTaskOptions = { + let opts: CreateTaskParams = { description: description, priority: priority, }; @@ -59,23 +53,22 @@ if (activeProject) { if (activeProject.value.type == "Project") { - opts.project_id = activeProject.value.id; + opts.projectId = activeProject.value.id; } else { - opts.section_id = activeProject.value.id; + opts.sectionId = activeProject.value.id; } } if (date) { - opts.due_date = date.format("YYYY-MM-DD"); + opts.dueDate = date.format("YYYY-MM-DD"); } - const result = await api.createTask(value, opts); - - if (result.isOk()) { + try { + await todoistAdapter.actions.createTask(value, opts); close(); new Notice("Task created successfully."); - } else { - new Notice(`Failed to create task: '${result.unwrapErr().message}'`); + } catch (error: any) { + new Notice(`Failed to create task: '${error}'`); } isBeingCreated = false; @@ -95,13 +88,13 @@
Project
- +
Labels
- +
diff --git a/src/modals/createTask/LabelSelector.svelte b/src/modals/createTask/LabelSelector.svelte index 203bf3a..a9d3ab3 100644 --- a/src/modals/createTask/LabelSelector.svelte +++ b/src/modals/createTask/LabelSelector.svelte @@ -1,22 +1,20 @@ - -
-
toggleFold(project.projectID)} - > - - - {metadata.projects.get_or_default(project.projectID, UnknownProject).name} - -
- {#if !foldedState.get(project.projectID)} - - - {#each project.sections as section (section.sectionID)} -
-
toggleFold(section.sectionID)} - > - - - {metadata.sections.get_or_default(section.sectionID, UnknownSection) - .name} - -
- {#if !foldedState.get(section.sectionID)} - - {/if} -
- {/each} - - {#each project.subProjects as childProj (childProj.projectID)} - - {/each} - {/if} -
diff --git a/src/ui/GroupedTasks.svelte b/src/ui/GroupedTasks.svelte new file mode 100644 index 0000000..090079b --- /dev/null +++ b/src/ui/GroupedTasks.svelte @@ -0,0 +1,21 @@ + + +{#each grouped as group (group.project.id)} +
+
+ {group.project.name} +
+ +
+{/each} diff --git a/src/ui/TaskList.svelte b/src/ui/TaskList.svelte index 6d876c6..4498191 100644 --- a/src/ui/TaskList.svelte +++ b/src/ui/TaskList.svelte @@ -1,59 +1,12 @@ -{#if todos.length != 0} -
    - {#each todos as todo (todo.id)} - - {/each} -
-{:else if renderNoTaskInfo} - -{/if} +
    + {#each taskTrees as taskTree (taskTree.id)} + + {/each} +
diff --git a/src/ui/TaskListRoot.svelte b/src/ui/TaskListRoot.svelte new file mode 100644 index 0000000..3b7f682 --- /dev/null +++ b/src/ui/TaskListRoot.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/ui/TaskRenderer.svelte b/src/ui/TaskRenderer.svelte index a3b2c12..613cff7 100644 --- a/src/ui/TaskRenderer.svelte +++ b/src/ui/TaskRenderer.svelte @@ -1,31 +1,53 @@

{title}

{ - callTaskModal(); + class={fetching + ? "edit-block-button todoist-refresh-button todoist-refresh-disabled" + : "edit-block-button todoist-refresh-button"} + on:click={async () => { + await forceRefresh(); }} - aria-label="Add item" + aria-label="Refresh list" > -
{ - await fetchTodos(); + class="edit-block-button todoist-add-button" + on:click={() => { + callTaskModal(); }} - aria-label="Refresh list" + aria-label="Add item" > - -

{#if fetchedOnce} - {#if query.group} - {#if groupedTasks.isOk()} - {#if groupedTasks.unwrap().length == 0} - - {:else} - {#each groupedTasks.unwrap() as project (project.projectID)} - - {/each} - {/if} - {:else} - - {/if} - {:else if tasks.isOk()} - + {#if filteredTasks.length === 0} + + {:else if query.group} + {:else} - + {/if} {/if} diff --git a/src/ui/contexts.ts b/src/ui/contexts.ts new file mode 100644 index 0000000..2fa500a --- /dev/null +++ b/src/ui/contexts.ts @@ -0,0 +1,16 @@ +import { getContext, setContext } from "svelte"; +import type { TaskId } from "../api/domain/task"; + +const taskActionsKey = "todoist-task-actions"; + +type TaskActions = { + close: (id: TaskId) => Promise, +} + +export function setTaskActions(actions: TaskActions) { + setContext(taskActionsKey, actions); +} + +export function getTaskActions(): TaskActions { + return getContext(taskActionsKey); +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 10985e4..12d098e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -29,5 +29,3 @@ export class ExtendedMap extends Map { return value; } } - -export const APP_CONTEXT_KEY = "obsidian_app"; diff --git a/src/utils/maybe.ts b/src/utils/maybe.ts new file mode 100644 index 0000000..20288d1 --- /dev/null +++ b/src/utils/maybe.ts @@ -0,0 +1,34 @@ +export class Maybe { + private value: T | undefined; + + static Empty(): Maybe { + return new Maybe(); + } + + static Some(val: T): Maybe { + const maybe = new Maybe(); + maybe.value = val; + return maybe; + } + + public insert(val: T) { + this.value = val; + } + + public hasValue(): boolean { + return this.value !== undefined; + } + + public inner(): T { + if (!this.hasValue()) { + throw new Error("tried to access inner value of empty Maybe"); + } + + return this.value; + } + + public withInner(func: (val: T) => U): U { + return func(this.inner()); + } +} + diff --git a/styles.css b/styles.css index 71aa94f..23850a7 100644 --- a/styles.css +++ b/styles.css @@ -3,15 +3,20 @@ font-size: 1.25em; } -.todoist-add-button { - margin-left: 8px; - margin-right: 66px; +.is-live-preview .todoist-refresh-button { + /* The refresh button sits next to the built-in 'Edit' button. So we need to place this excatly to the right of that one. But with --size-2-2 as buffer to it. */ + margin-right: calc(var(--icon-size) + 3 * var(--size-2-2)); +} + +.is-live-preview .todoist-add-button { + /* The add button is two buttons in, so we need to double the padding size */ + margin-right: calc(2 * var(--icon-size) + 6 * var(--size-2-2)); } -.todoist-refresh-button { - height: unset; +.markdown-reading-view .todoist-add-button, +.markdown-reading-view .todoist-refresh-button { + float: right; margin-left: 8px; - margin-right: 34px; } .todoist-refresh-disabled { @@ -29,7 +34,13 @@ } } -.task-metadata { +.markdown-reading-view .task-metadata, +.markdown-reading-view .todoist-task-description { + margin-left: 6px; +} + +.is-live-preview .task-metadata, +.is-live-preview .todoist-task-description { margin-left: 28px; } @@ -70,11 +81,6 @@ margin-top: 20px; } -.todoist-task-container { - text-indent: -38px; - padding-inline-start: 30px; -} - .todoist-task-content { display: inline; text-indent: 0px; @@ -89,11 +95,6 @@ ul.todoist-task-list { display: none; } -.is-live-preview .block-language-todoist { - padding-left: 15px; - padding-right: 10px; -} - .theme-dark { --todoist-p1-border: #ff7066; --todoist-p1-border-hover: #ff706680; @@ -144,6 +145,5 @@ ul.todoist-task-list { .todoist-task-description { font-size: 80%; color: var(--text-muted); - margin-left: 28px; } diff --git a/src/api/projects.test.ts b/tests_old/projects.test.ts similarity index 100% rename from src/api/projects.test.ts rename to tests_old/projects.test.ts diff --git a/src/api/task.test.ts b/tests_old/task.test.ts similarity index 100% rename from src/api/task.test.ts rename to tests_old/task.test.ts