maro-plugin-tasks 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json ADDED
@@ -0,0 +1,118 @@
1
+ {
2
+ "env": {
3
+ "node": true,
4
+ "es2021": true
5
+ },
6
+ "ignorePatterns": [
7
+ "dist"
8
+ ],
9
+ "extends": [
10
+ "eslint:recommended",
11
+ "plugin:de-morgan/recommended-legacy",
12
+ "plugin:@typescript-eslint/recommended",
13
+ "plugin:math/recommended-legacy",
14
+ "plugin:promise/recommended"
15
+ ],
16
+ "plugins": [
17
+ "promise",
18
+ "@stylistic",
19
+ "de-morgan",
20
+ "import",
21
+ "unused-imports"
22
+ ],
23
+ "parser": "@typescript-eslint/parser",
24
+ "parserOptions": {
25
+ "ecmaVersion": 12,
26
+ "sourceType": "module"
27
+ },
28
+ "rules": {
29
+ "no-inner-declarations": "off",
30
+ "import/no-deprecated": "error",
31
+ "import/no-cycle": "error",
32
+ "import/no-duplicates": "error",
33
+ "import/enforce-node-protocol-usage": [
34
+ "error",
35
+ "always"
36
+ ],
37
+ "unused-imports/no-unused-imports": "error",
38
+ "@stylistic/arrow-parens": 2,
39
+ "@stylistic/brace-style": 2,
40
+ "@stylistic/comma-dangle": 2,
41
+ "@stylistic/comma-spacing": 2,
42
+ "@stylistic/dot-location": [
43
+ 2,
44
+ "property"
45
+ ],
46
+ "@stylistic/func-call-spacing": 2,
47
+ "@stylistic/indent": [
48
+ "error",
49
+ 2
50
+ ],
51
+ "@stylistic/indent-binary-ops": [
52
+ "error",
53
+ 2
54
+ ],
55
+ "@stylistic/key-spacing": 2,
56
+ "@stylistic/keyword-spacing": 2,
57
+ "@stylistic/member-delimiter-style": 2,
58
+ "@stylistic/no-extra-parens": 2,
59
+ "@stylistic/no-extra-semi": 2,
60
+ "@stylistic/no-floating-decimal": 2,
61
+ "@stylistic/no-mixed-spaces-and-tabs": 2,
62
+ "@stylistic/no-multiple-empty-lines": [
63
+ 2,
64
+ {
65
+ "max": 1
66
+ }
67
+ ],
68
+ "@stylistic/no-tabs": 2,
69
+ "@stylistic/no-trailing-spaces": 2,
70
+ "@stylistic/no-whitespace-before-property": 2,
71
+ "@stylistic/nonblock-statement-body-position": 2,
72
+ "@stylistic/operator-linebreak": [
73
+ 2,
74
+ "before"
75
+ ],
76
+ "@stylistic/padding-line-between-statements": 2,
77
+ "@stylistic/quotes": [
78
+ "error",
79
+ "double"
80
+ ],
81
+ "@stylistic/semi": [
82
+ "error"
83
+ ],
84
+ "@stylistic/semi-spacing": 2,
85
+ "@stylistic/space-before-blocks": 2,
86
+ "@stylistic/space-in-parens": 2,
87
+ "@stylistic/space-infix-ops": 2,
88
+ "@stylistic/space-unary-ops": 2,
89
+ "@stylistic/spaced-comment": 2,
90
+ "@stylistic/template-curly-spacing": 2,
91
+ "@stylistic/type-annotation-spacing": 2,
92
+ "@stylistic/type-generic-spacing": 2,
93
+ "@stylistic/type-named-tuple-spacing": 2,
94
+ "@typescript-eslint/no-unused-vars": [
95
+ "error",
96
+ {
97
+ "varsIgnorePattern": "_+"
98
+ }
99
+ ],
100
+ "@typescript-eslint/no-explicit-any": "off",
101
+ "@typescript-eslint/ban-types": "off",
102
+ "arrow-spacing": "error",
103
+ "dot-notation": "error",
104
+ "eqeqeq": "error",
105
+ "no-alert": "error",
106
+ "no-const-assign": "error",
107
+ "no-multi-spaces": "error",
108
+ "no-multiple-empty-lines": "error",
109
+ "prefer-const": "error",
110
+ "quotes": [
111
+ "error",
112
+ "double",
113
+ {
114
+ "allowTemplateLiterals": true
115
+ }
116
+ ]
117
+ }
118
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "maro-plugin-tasks",
3
+ "version": "1.0.0",
4
+ "main": "./dist/lib/index.js",
5
+ "type": "commonjs",
6
+ "scripts": {
7
+ "build": "tsc --project ./tsconfig.json",
8
+ "start": "node ./dist/src/index.js",
9
+ "clean": "rm -rf node_modules package-lock.json"
10
+ },
11
+ "peerDependencies": {
12
+ "@maro/maro": "2.0.0"
13
+ },
14
+ "maro": {
15
+ "plugin": "./dist/src/index.js"
16
+ },
17
+ "keywords": [],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "description": "",
21
+ "devDependencies": {
22
+ "typescript": "^5.9.3",
23
+ "@typescript-eslint/eslint-plugin": "8.54.0",
24
+ "@stylistic/eslint-plugin": "2.6.5",
25
+ "eslint": "8.57.1",
26
+ "eslint-plugin-de-morgan": "2.0.0",
27
+ "eslint-plugin-import": "2.32.0",
28
+ "eslint-plugin-math": "0.13.1",
29
+ "eslint-plugin-promise": "7.2.1",
30
+ "eslint-plugin-unused-imports": "4.3.0"
31
+ }
32
+ }
@@ -0,0 +1,16 @@
1
+ import { getTasksFromDir } from "src/lib/utils";
2
+
3
+ import { Command } from "@maro/maro";
4
+
5
+ const OpenCommand: Command = {
6
+ name: "open",
7
+ description: "Open task from cwd in editor",
8
+ run: async ({ ctx }) => {
9
+ const tasks = getTasksFromDir(ctx.cwd);
10
+ const task = await ctx.ui.promptChoice(tasks, { message: "Choose task to open" });
11
+ task.openInEditor();
12
+ }
13
+ };
14
+
15
+ export default OpenCommand;
16
+
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import OpenCommand from "./commands/open";
2
+ import { PluginExport } from "../../../dist/lib";
3
+
4
+ const plugin: PluginExport = {
5
+ name: "maro-plugin-tasks",
6
+ commands: [
7
+ {
8
+ name: "task",
9
+ description: "Track tasks asociated with TODOS",
10
+ aliases: [],
11
+ subcommands: [
12
+ OpenCommand
13
+ ]
14
+ }
15
+ ]
16
+ };
17
+
18
+ export default plugin;
@@ -0,0 +1,32 @@
1
+ import { Action, ActionRegistry, loading, MrCreateEvent } from "@maro/maro";
2
+
3
+ import { TaskTracker } from "./tracker";
4
+ import { getTasksFromDir } from "./utils";
5
+
6
+ export class CreateMissingTasksAction implements Action {
7
+ tracker: TaskTracker;
8
+
9
+ constructor(tracker: TaskTracker) {
10
+ this.tracker = tracker;
11
+ }
12
+
13
+ register(): void {
14
+ ActionRegistry.on(MrCreateEvent, (event) => this.execute(event));
15
+ }
16
+
17
+ @loading("Generating missing issues")
18
+ async execute(event: MrCreateEvent) {
19
+ const repo = event.ctx;
20
+ const tasks = getTasksFromDir(repo.dir);
21
+
22
+ await Promise.all(
23
+ tasks.map(async (t) => {
24
+ if (this.tracker.isTracked(t)) return;
25
+ await this.tracker.track(t);
26
+ await repo.add(t.file_location.file);
27
+ }));
28
+ await repo.commit("fix: add issues");
29
+ }
30
+
31
+ }
32
+
@@ -0,0 +1,3 @@
1
+ export { CreateMissingTasksAction } from "./action";
2
+ export { Task } from "./task";
3
+ export { TaskTracker } from "./tracker";
@@ -0,0 +1,175 @@
1
+ import { Repo } from "@maro/maro";
2
+
3
+ import { Task } from "./task";
4
+ import { TaskTracker } from "./tracker";
5
+
6
+ export function sleep(ms: number) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ export const TASK_FILE = "TASK.md";
11
+
12
+ // TODO: for now Tasks change status manually outside the app,
13
+ // add support for trackers to change task status, could be useful for RepoActions
14
+ const TASK_STATUS = {
15
+ OPEN: "OPEN",
16
+ CLOSED: "CLOSED"
17
+ } as const;
18
+
19
+ export class RepoTaskTracker extends TaskTracker {
20
+ repo: Repo;
21
+
22
+ constructor(repo: Repo) {
23
+ super();
24
+ this.repo = repo;
25
+ }
26
+
27
+ override addIdToTodo(id: string): string {
28
+ return `(${id})`;
29
+ }
30
+
31
+ override async save(task: Task): Promise<string> {
32
+ const d = new Date();
33
+ const pad = (n: number) => String(n).padStart(2, "0");
34
+ const YYYY = d.getFullYear();
35
+ const MM = pad(d.getMonth() + 1);
36
+ const DD = pad(d.getDate());
37
+ const HH = pad(d.getHours());
38
+ const mm = pad(d.getMinutes());
39
+ const SS = pad(d.getSeconds());
40
+ await sleep(1 * 1000);
41
+ const id = `${YYYY}${MM}${DD}-${HH}${mm}${SS}`;
42
+
43
+ const tasks_dir = this.repo.dir.sub("tasks");
44
+ tasks_dir.create();
45
+ const task_dir = tasks_dir.sub(id);
46
+ task_dir.createFile(TASK_FILE).write(this.toMdString(task));
47
+ return id;
48
+ }
49
+
50
+ override isTracked(task: Task): boolean {
51
+ const todoLine = task.getTodo();
52
+ const regex = /TODO[^:]*\([^)]*\)[^:]*:/;
53
+ return regex.test(todoLine);
54
+ }
55
+
56
+ private toMdString(task: Task) {
57
+ const relative = task.getPathInProject();
58
+ return `# ${task.title}
59
+
60
+ - PROJECT: ${task.project}
61
+ - FILE-LOCATION: ${relative}:${task.file_location.row}:${task.file_location.col}
62
+ - STATUS: ${TASK_STATUS.OPEN}
63
+
64
+ ${task.description} `;
65
+ }
66
+
67
+ // async getTasks(project?: string) {
68
+ // await this.update();
69
+ // const dirs = project ? [project] : readdirs(this.full_path).map((d) => d.name);
70
+ // const full_dirs = dirs.map((d) => path.join(this.full_path, d));
71
+ // return full_dirs.flatMap((d) => readdirs(d).map((task_dir) => Task.fromTaskFile(path.join(d, task_dir.name, TASK_FILE))));
72
+ // }
73
+ //
74
+ // addTask(task: Task) {
75
+ // const dir = path.join(this.full_path, task.project, task.id);
76
+ // const task_path = path.join(dir, TASK_FILE);
77
+ // createDirIfNotExists(dir);
78
+ // task.save(task_path);
79
+ // }
80
+
81
+ // TODO: move to TaskRepo
82
+ // private static parseFileLocation(raw: string): FileLocation {
83
+ // const [file_path, row, col] = raw.split(":");
84
+ // if (!file_path) throw new Error(`Could not parse file location ${raw} file_path missing`);
85
+ // return { file_path, row: Number(row), col: Number(col) };
86
+ // }
87
+
88
+ // TODO: move to TaskRepo
89
+ // static fromTaskFile(file: string) {
90
+ // const raw = fs.readFileSync(file, "utf8");
91
+ //
92
+ // const tree = fromMarkdown(raw);
93
+ //
94
+ // let title: string | undefined;
95
+ // let id: string | undefined;
96
+ // let project: string | undefined;
97
+ // let status: TaskStatus | undefined;
98
+ // let priority: number | undefined;
99
+ // let fileLocation: FileLocation | undefined;
100
+ // let jiraId: string | undefined;
101
+ // let description = "";
102
+ //
103
+ // for (const node of tree.children) {
104
+ // if (node.type === "heading" && node.depth === 1) {
105
+ // if (!node.children[0] || !("value" in node.children[0])) continue;
106
+ // title = node.children[0]?.value.trim();
107
+ // continue;
108
+ // }
109
+ //
110
+ // if (node.type === "list") {
111
+ // for (const item of node.children) {
112
+ // if (!item.children[0]
113
+ // || !("children" in item.children[0])
114
+ // || !item.children[0].children[0]
115
+ // || !("value" in item.children[0].children[0])) continue;
116
+ // const text = item.children[0].children[0]?.value;
117
+ // const [key, rawVal, ...rest] = text.split(":");
118
+ // const value = [rawVal, ...rest].join(":")?.trim();
119
+ // switch (key) {
120
+ // case "ID":
121
+ // id = value;
122
+ // break;
123
+ // case "PROJECT":
124
+ // project = value;
125
+ // break;
126
+ // case "STATUS": {
127
+ // const statuses = Object.values(TASK_STATUS);
128
+ // if (!statuses.includes(value as TaskStatus)) throw new Error(`Invalid STATUS "${value}". Must be one of: ${statuses.join(", ")}`);
129
+ // status = value as TaskStatus;
130
+ // break;
131
+ // }
132
+ // case "PRIORITY":
133
+ // priority = Number(value);
134
+ // break;
135
+ // case "JIRA-ID":
136
+ // jiraId = value;
137
+ // break;
138
+ // case "FILE-LOCATION":
139
+ // fileLocation = this.parseFileLocation(value ?? "");
140
+ // break;
141
+ // }
142
+ // }
143
+ // continue;
144
+ // }
145
+ //
146
+ // if (node.type === "paragraph") {
147
+ // const text = node.children.map((c) => {
148
+ // if (!("value" in c)) return "";
149
+ // return c.value;
150
+ // }).join("").trim();
151
+ // if (text) description += (description ? "\n" : "") + text;
152
+ // }
153
+ // }
154
+ //
155
+ // if (!id) throw new Error(`Could not find id for task ${file}`);
156
+ // if (!project) throw new Error(`Could not find project for task ${file}`);
157
+ // if (!title) throw new Error(`Could not find title for task ${id} from project ${project}`);
158
+ // if (!priority) throw new Error(`Could not find priority for task ${id} from project ${project}`);
159
+ // if (!status) throw new Error(`Could not find status for task ${id} from project ${project}`);
160
+ //
161
+ // return new Task({
162
+ // md_path: file,
163
+ // id,
164
+ // title,
165
+ // project,
166
+ // description,
167
+ // tags: {
168
+ // status,
169
+ // priority,
170
+ // jira_id: jiraId,
171
+ // file_location: fileLocation
172
+ // }
173
+ // });
174
+ // }
175
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ Choice,
3
+ Dir,
4
+ openInEditor,
5
+ TextFile
6
+ } from "@maro/maro";
7
+
8
+ type FileLocation = {
9
+ file: TextFile;
10
+ row: number;
11
+ col: number;
12
+ };
13
+
14
+ export class Task {
15
+ project: Dir;
16
+
17
+ title: string;
18
+ description?: string;
19
+ file_location: FileLocation;
20
+
21
+ constructor({ title, file_location, project, description }: {
22
+ description?: string;
23
+ project: Dir;
24
+ title: string;
25
+ file_location: FileLocation;
26
+ }) {
27
+ const file = file_location.file;
28
+ if (!project.contains(file)) throw new Error(`${file} is not in ${project}`);
29
+ this.title = title;
30
+ this.file_location = file_location;
31
+ this.project = project;
32
+ this.description = description;
33
+ }
34
+
35
+ getTodo() {
36
+ const { file, row } = this.file_location;
37
+ const lines = file.read().split(/\r?\n/);
38
+ const normalized_row = row - 1;
39
+ return lines[normalized_row]!;
40
+ }
41
+
42
+ editTodo(add: string) {
43
+ const todoLine = this.getTodo();
44
+ const { file, col, row } = this.file_location;
45
+ const normalized_row = row - 1;
46
+ const normalized_col = col - 1;
47
+
48
+ const prefix = todoLine.slice(0, normalized_col);
49
+ const afterTodo = todoLine.slice(normalized_col);
50
+ const colonIndex = afterTodo.indexOf(":");
51
+
52
+ const originalDecorations = colonIndex === -1
53
+ ? afterTodo.slice("TODO".length)
54
+ : afterTodo.slice("TODO".length, colonIndex);
55
+
56
+ const originalBody = colonIndex === -1 ? "" : afterTodo.slice(colonIndex + 1).trim();
57
+
58
+ const newTodo = prefix + "TODO" + originalDecorations + add + ": " + originalBody;
59
+ const lines = file.read().split(/\r?\n/);
60
+ lines[normalized_row] = newTodo;
61
+ file.write(lines.join("\n"));
62
+ }
63
+
64
+ openInEditor() {
65
+ const { col, row } = this.file_location;
66
+ openInEditor(this.file_location.file, { line: row, column: col });
67
+ }
68
+
69
+ getPathInProject() {
70
+ return this.project.relativePathTo(this.file_location.file);
71
+ }
72
+
73
+ toChoice(): Choice {
74
+ return { name: this.title, hint: `${this.file_location.file}:${this.file_location.row}:${this.file_location.col}` };
75
+ }
76
+
77
+ }
@@ -0,0 +1,15 @@
1
+ import { Task } from "./task";
2
+
3
+ export abstract class TaskTracker {
4
+
5
+ abstract save(task: Task): Promise<string>;
6
+ abstract addIdToTodo(id: string, task: Task): string;
7
+ abstract isTracked(task: Task): boolean;
8
+
9
+ async track(task: Task) {
10
+ if (this.isTracked(task)) return;
11
+ const id = await this.save(task);
12
+ task.editTodo(this.addIdToTodo(id, task) ?? "");
13
+ }
14
+ }
15
+
@@ -0,0 +1,29 @@
1
+ import { Dir, TextFile } from "@maro/maro";
2
+
3
+ import { Task } from "./task";
4
+
5
+ export const getTasksFromDir = (dir: Dir) => {
6
+ const files = dir.traverse();
7
+ const tasks: Task[] = [];
8
+ for (const f of files) tasks.push(...getTasksFromFile(dir, f));
9
+ return tasks;
10
+ };
11
+
12
+ export const getTasksFromFile = (project: Dir, fileInDir: TextFile) => {
13
+ const lines = fileInDir.read().split(/\r?\n/);
14
+ const results: Task[] = [];
15
+ const regex = /TODO.*:\s*(.*)/g;
16
+ for (let row = 0; row < lines.length; row++) {
17
+ const line = lines[row]!;
18
+ let match;
19
+ while ((match = regex.exec(line)) !== null) {
20
+ const col = match.index;
21
+ const title = match[1]?.trim() ?? "";
22
+ const file_location = { file: fileInDir, row: row + 1, col: col + 1 };
23
+ // TODO: Task.description from file
24
+ results.push(new Task({ title, project, file_location }));
25
+ }
26
+ }
27
+ return results;
28
+ };
29
+
package/tsconfig.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "es2021",
6
+ "dom"
7
+ ],
8
+ "module": "commonjs",
9
+ "outDir": "./dist",
10
+ "rootDir": "./",
11
+ "baseUrl": "./",
12
+ "strict": true,
13
+ "moduleResolution": "node",
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "noImplicitAny": true,
18
+ "strictNullChecks": true,
19
+ "strictFunctionTypes": true,
20
+ "strictBindCallApply": true,
21
+ "strictPropertyInitialization": true,
22
+ "noImplicitThis": true,
23
+ "useUnknownInCatchVariables": true,
24
+ "experimentalDecorators": true,
25
+ "useDefineForClassFields": false,
26
+ "alwaysStrict": true,
27
+ "noUnusedLocals": true,
28
+ "noUnusedParameters": true,
29
+ "noImplicitReturns": true,
30
+ "noFallthroughCasesInSwitch": true,
31
+ "noImplicitOverride": true,
32
+ "allowUnusedLabels": false,
33
+ "allowUnreachableCode": false,
34
+ "noUncheckedIndexedAccess": true,
35
+ "noEmit": false,
36
+ "noEmitOnError": false
37
+ }
38
+ }