feat: tasks workflow
This commit is contained in:
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"type": "npm",
|
||||||
"script": "compile",
|
"script": "build",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build",
|
"kind": "build",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
|
|||||||
+93
-25
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
|
id: "..."
|
||||||
title: "Пофиксить баг логина"
|
title: "Пофиксить баг логина"
|
||||||
status: todo # todo | in-progress | done | cancelled
|
status: todo # todo | in-progress | done | cancelled
|
||||||
priority: high # low | medium | high | critical
|
priority: high # low | medium | high | critical
|
||||||
@@ -21,7 +22,7 @@ updated: 2026-07-12T10:00:00Z
|
|||||||
**Структура папок:**
|
**Структура папок:**
|
||||||
|
|
||||||
- `.vscode/tasks/` — проектные задачи
|
- `.vscode/tasks/` — проектные задачи
|
||||||
- `~/project-tasks/` — глобальные задачи (настраивается)
|
- глобальная папка — настраивается (`projectTasks.globalPath`)
|
||||||
|
|
||||||
Оба пути настраиваются через VS Code settings.
|
Оба пути настраиваются через VS Code settings.
|
||||||
|
|
||||||
@@ -29,50 +30,117 @@ updated: 2026-07-12T10:00:00Z
|
|||||||
|
|
||||||
Через **Custom Editor** + **WebView**:
|
Через **Custom Editor** + **WebView**:
|
||||||
|
|
||||||
1. **Activity Bar** — своя вкладка с Task Dashboard
|
1. **Activity Bar** — вкладка Project Tasks
|
||||||
2. **Task Dashboard** — WebView в editor area со split layout:
|
2. **Task Dashboard** — WebView в editor area со split layout:
|
||||||
- Левая панель — список задач с фильтрацией/группировкой
|
- Левая панель — список задач с фильтрацией/группировкой
|
||||||
- Правая панель — содержимое выбранной задачи (редактируемый markdown)
|
- Правая панель — содержимое выбранной задачи (редактируемый markdown)
|
||||||
3. **TreeView в Explorer** — быстрый просмотр
|
3. **TreeView** — быстрый просмотр по статусу
|
||||||
|
|
||||||
## Архитектура кода
|
## Архитектура кода
|
||||||
|
|
||||||
|
Актуальная раскладка (после рефакторинга портов/адаптеров):
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
src/
|
src/
|
||||||
├── extension.ts # Активация, регистрация
|
├── extension.ts # composition root (DI, register)
|
||||||
|
├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider
|
||||||
|
├── error.ts # AppError + factories (единый каталог ошибок)
|
||||||
|
├── model/task.ts
|
||||||
├── storage/
|
├── storage/
|
||||||
│ ├── taskStore.ts # CRUD для .task.md (gray-matter парсинг)
|
│ ├── FsTaskRepository.ts
|
||||||
│ └── configStore.ts # Настройки
|
│ ├── NodeFileSystem.ts
|
||||||
├── model/
|
│ └── VscodeConfigProvider.ts
|
||||||
│ └── task.ts # Интерфейс Task + типы
|
├── commands/ # use-cases без vscode UI
|
||||||
├── views/
|
|
||||||
│ ├── taskTreeProvider.ts # TreeView в sidebar
|
|
||||||
│ ├── taskListProvider.ts # Data provider для WebView
|
|
||||||
│ └── taskDashboardPanel.ts # WebView split layout
|
|
||||||
├── commands/
|
|
||||||
│ ├── createTask.ts
|
│ ├── createTask.ts
|
||||||
│ ├── openTask.ts
|
│ ├── openTask.ts
|
||||||
│ ├── deleteTask.ts
|
│ ├── deleteTask.ts
|
||||||
│ └── changeStatus.ts
|
│ └── changeStatus.ts
|
||||||
|
├── views/
|
||||||
|
│ └── taskTreeProvider.ts # (+ dashboard later)
|
||||||
└── utils/
|
└── utils/
|
||||||
├── uuid.ts
|
├── uuid.ts
|
||||||
└── markdown.ts
|
└── markdown.ts # parse/serialize frontmatter
|
||||||
|
|
||||||
|
tests/helpers/InMemoryFileSystem.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
UI (input box, quick pick, open editor) — тонкие адаптеры; бизнес-логика в
|
||||||
|
use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`.
|
||||||
|
|
||||||
## План реализации
|
## План реализации
|
||||||
|
|
||||||
| Фаза | Что делаем |
|
| Фаза | Что делаем |
|
||||||
| ------------------------ | -------------------------------------------------------------- |
|
| ------------------------ | ---------------------------------------------------------------------- |
|
||||||
| **1. Scaffold** | `yo code`, TypeScript, настройка package.json |
|
| **1. Scaffold** | Extension, TypeScript, сборка, package.json |
|
||||||
| **2. Storage** | TaskStore — чтение `.task.md`, парсинг через gray-matter, CRUD |
|
| **2. Storage** | CRUD `.task.md`, парсинг frontmatter, unit-тесты storage |
|
||||||
| **3. TreeView** | Боковая панель со списком задач по статусу |
|
| **3. TreeView** | sidebar по статусу, file watcher |
|
||||||
| **4. Команды** | createTask, deleteTask, changeStatus, openTask |
|
| **4. Команды** | createTask, deleteTask, changeStatus, openTask (use-cases → wiring UI) |
|
||||||
| **5. Task Dashboard** | WebView split layout в editor area |
|
| **5. Task Dashboard** | WebView split layout |
|
||||||
| **6. Глобальные задачи** | Второй storage (глобальная папка) |
|
| **6. Глобальные задачи** | использование globalPath |
|
||||||
| **7. Kanban** | Drag-n-drop доска |
|
| **7. Kanban** | drag-n-drop |
|
||||||
| **8. Публикация** | vsce publish |
|
| **8. Публикация** | vsce publish |
|
||||||
|
|
||||||
|
### Фаза 4 — команды (use-cases)
|
||||||
|
|
||||||
|
| Команда | Поведение |
|
||||||
|
| -------------- | -------------------------------------------------------------- |
|
||||||
|
| `createTask` | title + folder → defaults todo/medium; empty title / no folder |
|
||||||
|
| `deleteTask` | folder + id → delete; not found |
|
||||||
|
| `changeStatus` | folder + id + status → update; not found |
|
||||||
|
| `openTask` | folder + id → path + task для editor; not found |
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
- `gray-matter` — парсинг YAML frontmatter
|
- `front-matter` — разбор YAML frontmatter (body + attributes)
|
||||||
|
- `js-yaml` — сериализация frontmatter обратно в YAML (`dump`)
|
||||||
- `uuid` — генерация ID
|
- `uuid` — генерация ID
|
||||||
|
- `neverthrow` — `Result` / `ResultAsync` для use-cases
|
||||||
|
- `vitest` / `eslint` — dev
|
||||||
|
|
||||||
|
## История изменений (решения, не чеклист)
|
||||||
|
|
||||||
|
Краткая летопись смен относительно **изначального** наброска плана. Детали
|
||||||
|
рефакторинга storage/тестов — в архиве `docs/REFACTOR-AND-TEST.md` (документ
|
||||||
|
закрыт, не ведём).
|
||||||
|
|
||||||
|
### Изначальный набросок (v0)
|
||||||
|
|
||||||
|
- Парсинг: **`gray-matter`**
|
||||||
|
- Storage: `taskStore.ts` (CRUD), `configStore.ts` (settings)
|
||||||
|
- Сборка/скелет: `yo code`-ориентированный scaffold
|
||||||
|
- Дерево модулей: storage + views (tree/list/dashboard) + commands + utils
|
||||||
|
|
||||||
|
### Рефакторинг storage / чистая архитектура
|
||||||
|
|
||||||
|
- `taskStore` / `configStore` → **порты** (`IFileSystem`, `ITaskRepository`,
|
||||||
|
`IConfigProvider`) и адаптеры:
|
||||||
|
- `FsTaskRepository`, `NodeFileSystem`, `VscodeConfigProvider`
|
||||||
|
- `extension.ts` — composition root (DI), tree зависит от портов
|
||||||
|
- Unit-тесты: Vitest, colocation, `InMemoryFileSystem` в `tests/helpers/`
|
||||||
|
|
||||||
|
### `gray-matter` → `front-matter` + `js-yaml`
|
||||||
|
|
||||||
|
- **Было в плане:** один пакет `gray-matter` на parse (+ stringify при
|
||||||
|
необходимости).
|
||||||
|
- **Стало:** `front-matter` для чтения attributes/body; `js-yaml` (`dump`) для
|
||||||
|
записи frontmatter в `serializeTask`.
|
||||||
|
- **Зачем:** `gray-matter` даёт лишние возможности парсинга (eval для JS и
|
||||||
|
прочая «магия»), которые для задач не нужны; Vite на eval ругается, с точки
|
||||||
|
зрения безопасности это нежелательно. Поэтому parse через `front-matter`,
|
||||||
|
serialize через `js-yaml` (`dump`). Формат файла тот же: Markdown + YAML
|
||||||
|
frontmatter.
|
||||||
|
|
||||||
|
### neverthrow + `AppError`
|
||||||
|
|
||||||
|
- Use-cases возвращают `ResultAsync<T, AppError>` вместо кастомных
|
||||||
|
`{ ok, reason }`.
|
||||||
|
- Каталог ошибок: `src/error.ts` (`AppErrorCode`, factories `AppError.noFolder()`
|
||||||
|
и т.д.).
|
||||||
|
|
||||||
|
### Прочее относительно v0
|
||||||
|
|
||||||
|
- Сборка: Vite (extension bundle), не классический `tsc`-only из yo code
|
||||||
|
- В модели/файле задачи зафиксирован **`id`** в frontmatter (нужен для
|
||||||
|
list/get/update/delete)
|
||||||
|
- Фаза 4: use-cases в `src/commands/*` тестируются без vscode; UI — отдельные
|
||||||
|
адаптеры
|
||||||
|
|||||||
Generated
+29
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"js-yaml": "^5.2.1",
|
"js-yaml": "^5.2.1",
|
||||||
|
"neverthrow": "^8.2.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
@@ -727,6 +728,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.62.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
|
||||||
|
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2358,6 +2375,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/neverthrow": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "^4.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"js-yaml": "^5.2.1",
|
"js-yaml": "^5.2.1",
|
||||||
|
"neverthrow": "^8.2.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { err } from "neverthrow";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { changeStatus } from "./changeStatus";
|
||||||
|
|
||||||
|
describe("changeStatus", () => {
|
||||||
|
const folder = "/tasks";
|
||||||
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates status of an existing task", async () => {
|
||||||
|
const created = await repo.create(folder, {
|
||||||
|
title: "Work item",
|
||||||
|
description: "",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await changeStatus(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: created.id,
|
||||||
|
status: "in-progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.status).toBe("in-progress");
|
||||||
|
expect(result.value.id).toBe(created.id);
|
||||||
|
|
||||||
|
const stored = await repo.getById(folder, created.id);
|
||||||
|
expect(stored?.status).toBe("in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not_found for unknown id", async () => {
|
||||||
|
const result = await changeStatus(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "missing",
|
||||||
|
status: "done",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.notFound()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await changeStatus(repo, {
|
||||||
|
folderPath: undefined,
|
||||||
|
id: "x",
|
||||||
|
status: "done",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noFolder()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", async () => {
|
||||||
|
const result = await changeStatus(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: undefined,
|
||||||
|
status: "done",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noId()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing status", async () => {
|
||||||
|
const result = await changeStatus(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "x",
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noStatus()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { Task, TaskStatus } from "@/model/task";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
|
||||||
|
export type ChangeStatusInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
id?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function changeStatus(
|
||||||
|
repo: ITaskRepository,
|
||||||
|
input: ChangeStatusInput,
|
||||||
|
): ResultAsync<Task, AppError> {
|
||||||
|
if (input.folderPath === undefined) {
|
||||||
|
return errAsync(AppError.noFolder());
|
||||||
|
}
|
||||||
|
if (input.id === undefined) {
|
||||||
|
return errAsync(AppError.noId());
|
||||||
|
}
|
||||||
|
if (input.status === undefined) {
|
||||||
|
return errAsync(AppError.noStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = input.folderPath;
|
||||||
|
const id = input.id;
|
||||||
|
const status = input.status;
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(existing) => {
|
||||||
|
if (!existing) {
|
||||||
|
return errAsync(AppError.notFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Task = { ...existing, status };
|
||||||
|
return ResultAsync.fromSafePromise(repo.update(folderPath, next)).andThen(
|
||||||
|
() =>
|
||||||
|
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(updated) =>
|
||||||
|
updated ? okAsync(updated) : errAsync(AppError.notFound()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { err } from "neverthrow";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { createTask } from "./createTask";
|
||||||
|
|
||||||
|
describe("createTask", () => {
|
||||||
|
const folder = "/tasks";
|
||||||
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a todo/medium task from title", async () => {
|
||||||
|
const result = await createTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
title: " New feature ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
|
||||||
|
expect(result.value.title).toBe("New feature");
|
||||||
|
expect(result.value.status).toBe("todo");
|
||||||
|
expect(result.value.priority).toBe("medium");
|
||||||
|
expect(result.value.description).toBe("");
|
||||||
|
expect(result.value.tags).toEqual([]);
|
||||||
|
expect(result.value.assignee).toBe("");
|
||||||
|
|
||||||
|
const listed = await repo.list(folder);
|
||||||
|
expect(listed).toHaveLength(1);
|
||||||
|
expect(listed[0].id).toBe(result.value.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty title", async () => {
|
||||||
|
const result = await createTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
title: " ",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.emptyTitle()));
|
||||||
|
expect(await repo.list(folder)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing title", async () => {
|
||||||
|
const result = await createTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
title: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.emptyTitle()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await createTask(repo, {
|
||||||
|
folderPath: undefined,
|
||||||
|
title: "Task",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noFolder()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { errAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
|
||||||
|
export type CreateTaskInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTask(
|
||||||
|
repo: ITaskRepository,
|
||||||
|
input: CreateTaskInput,
|
||||||
|
): ResultAsync<Task, AppError> {
|
||||||
|
if (input.folderPath === undefined) {
|
||||||
|
return errAsync(AppError.noFolder());
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = input.title?.trim() ?? "";
|
||||||
|
if (!title) {
|
||||||
|
return errAsync(AppError.emptyTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(
|
||||||
|
repo.create(input.folderPath, {
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { err, ok } from "neverthrow";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { deleteTask } from "./deleteTask";
|
||||||
|
|
||||||
|
describe("deleteTask", () => {
|
||||||
|
const folder = "/tasks";
|
||||||
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an existing task", async () => {
|
||||||
|
const created = await repo.create(folder, {
|
||||||
|
title: "To delete",
|
||||||
|
description: "",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await deleteTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: created.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(ok(undefined));
|
||||||
|
expect(await repo.list(folder)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not_found for unknown id", async () => {
|
||||||
|
const result = await deleteTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "missing",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.notFound()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await deleteTask(repo, {
|
||||||
|
folderPath: undefined,
|
||||||
|
id: "x",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noFolder()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", async () => {
|
||||||
|
const result = await deleteTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noId()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { errAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
|
||||||
|
export type DeleteTaskInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deleteTask(
|
||||||
|
repo: ITaskRepository,
|
||||||
|
input: DeleteTaskInput,
|
||||||
|
): ResultAsync<void, AppError> {
|
||||||
|
if (input.folderPath === undefined) {
|
||||||
|
return errAsync(AppError.noFolder());
|
||||||
|
}
|
||||||
|
if (input.id === undefined) {
|
||||||
|
return errAsync(AppError.noId());
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = input.folderPath;
|
||||||
|
const id = input.id;
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(existing) => {
|
||||||
|
if (!existing) {
|
||||||
|
return errAsync(AppError.notFound());
|
||||||
|
}
|
||||||
|
return ResultAsync.fromSafePromise(repo.delete(folderPath, id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { err } from "neverthrow";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { openTask } from "./openTask";
|
||||||
|
|
||||||
|
describe("openTask", () => {
|
||||||
|
const folder = "/tasks";
|
||||||
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns file path and task for an existing id", async () => {
|
||||||
|
const created = await repo.create(folder, {
|
||||||
|
title: "Open me",
|
||||||
|
description: "body",
|
||||||
|
status: "todo",
|
||||||
|
priority: "high",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await openTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: created.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.path).toBe("/tasks/open-me.task.md");
|
||||||
|
expect(result.value.task).toEqual(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not_found for unknown id", async () => {
|
||||||
|
const result = await openTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "missing",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.notFound()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await openTask(repo, {
|
||||||
|
folderPath: undefined,
|
||||||
|
id: "x",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noFolder()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", async () => {
|
||||||
|
const result = await openTask(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(AppError.noId()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { AppError } from "@/error";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
|
||||||
|
export type OpenTaskInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenTaskValue = {
|
||||||
|
path: string;
|
||||||
|
task: Task;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function openTask(
|
||||||
|
repo: ITaskRepository,
|
||||||
|
input: OpenTaskInput,
|
||||||
|
): ResultAsync<OpenTaskValue, AppError> {
|
||||||
|
if (input.folderPath === undefined) {
|
||||||
|
return errAsync(AppError.noFolder());
|
||||||
|
}
|
||||||
|
if (input.id === undefined) {
|
||||||
|
return errAsync(AppError.noId());
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = input.folderPath;
|
||||||
|
const id = input.id;
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(task) => {
|
||||||
|
if (!task) {
|
||||||
|
return errAsync(AppError.notFound());
|
||||||
|
}
|
||||||
|
return okAsync({
|
||||||
|
path: repo.getFilePath(folderPath, task),
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** All application-level error codes. */
|
||||||
|
export type AppErrorCode =
|
||||||
|
| "no_folder"
|
||||||
|
| "no_id"
|
||||||
|
| "no_status"
|
||||||
|
| "empty_title"
|
||||||
|
| "not_found";
|
||||||
|
|
||||||
|
/** Discriminated app error returned via neverthrow `Result` / `ResultAsync`. */
|
||||||
|
export type AppError = {
|
||||||
|
readonly code: AppErrorCode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppError = {
|
||||||
|
noFolder: (): AppError => ({ code: "no_folder" }),
|
||||||
|
noId: (): AppError => ({ code: "no_id" }),
|
||||||
|
noStatus: (): AppError => ({ code: "no_status" }),
|
||||||
|
emptyTitle: (): AppError => ({ code: "empty_title" }),
|
||||||
|
notFound: (): AppError => ({ code: "not_found" }),
|
||||||
|
} as const;
|
||||||
@@ -17,6 +17,8 @@ export interface ITaskRepository {
|
|||||||
): Promise<Task>;
|
): Promise<Task>;
|
||||||
update(folderPath: string, task: Task): Promise<void>;
|
update(folderPath: string, task: Task): Promise<void>;
|
||||||
delete(folderPath: string, id: string): Promise<void>;
|
delete(folderPath: string, id: string): Promise<void>;
|
||||||
|
/** Absolute/joined path for the task file in `folderPath`. */
|
||||||
|
getFilePath(folderPath: string, task: Task): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IConfigProvider {
|
export interface IConfigProvider {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class FsTaskRepository implements ITaskRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.#fs.mkdir(folderPath);
|
await this.#fs.mkdir(folderPath);
|
||||||
const filePath = this.taskFilePath(folderPath, newTask);
|
const filePath = this.getFilePath(folderPath, newTask);
|
||||||
await this.#fs.writeFile(filePath, serializeTask(newTask));
|
await this.#fs.writeFile(filePath, serializeTask(newTask));
|
||||||
|
|
||||||
return newTask;
|
return newTask;
|
||||||
@@ -77,10 +77,10 @@ export class FsTaskRepository implements ITaskRepository {
|
|||||||
async update(folderPath: string, task: Task): Promise<void> {
|
async update(folderPath: string, task: Task): Promise<void> {
|
||||||
const existing = await this.getById(folderPath, task.id);
|
const existing = await this.getById(folderPath, task.id);
|
||||||
const updatedTask = { ...task, updated: this.#now() };
|
const updatedTask = { ...task, updated: this.#now() };
|
||||||
const newPath = this.taskFilePath(folderPath, updatedTask);
|
const newPath = this.getFilePath(folderPath, updatedTask);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const oldPath = this.taskFilePath(folderPath, existing);
|
const oldPath = this.getFilePath(folderPath, existing);
|
||||||
if (oldPath !== newPath) {
|
if (oldPath !== newPath) {
|
||||||
await this.#fs.deleteFile(oldPath).catch(() => {});
|
await this.#fs.deleteFile(oldPath).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ export class FsTaskRepository implements ITaskRepository {
|
|||||||
const task = await this.getById(folderPath, id);
|
const task = await this.getById(folderPath, id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
const filePath = this.taskFilePath(folderPath, task);
|
const filePath = this.getFilePath(folderPath, task);
|
||||||
try {
|
try {
|
||||||
await this.#fs.deleteFile(filePath);
|
await this.#fs.deleteFile(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -101,7 +101,7 @@ export class FsTaskRepository implements ITaskRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private taskFilePath(folderPath: string, task: Task): string {
|
getFilePath(folderPath: string, task: Task): string {
|
||||||
const safeName = task.title
|
const safeName = task.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
||||||
|
|||||||
Reference in New Issue
Block a user