feat: add tags workflow
This commit is contained in:
@@ -14,7 +14,8 @@ cloud, no server: git-friendly, editable in any editor.
|
|||||||
- **Dashboard** — task list + description (filters, scope, sort, group)
|
- **Dashboard** — task list + description (filters, scope, sort, group)
|
||||||
- **Kanban** — status columns, drag-and-drop, create-in-column
|
- **Kanban** — status columns, drag-and-drop, create-in-column
|
||||||
- **Commands** — create / open / change status / delete
|
- **Commands** — create / open / change status / delete
|
||||||
- **In-editor** — frontmatter lint + status/priority Quick Pick (decorations)
|
- **In-editor** — frontmatter lint + status/priority/tags/assignee actions
|
||||||
|
- **Project tags** — catalog in task folder `config.yml`
|
||||||
- **Two stores** — project folder (workspace) and global folder (shared on the
|
- **Two stores** — project folder (workspace) and global folder (shared on the
|
||||||
machine)
|
machine)
|
||||||
|
|
||||||
@@ -119,6 +120,26 @@ Task description in markdown…
|
|||||||
One file per task (`*.xflow.md`). File name is derived from the title (slug).
|
One file per task (`*.xflow.md`). File name is derived from the title (slug).
|
||||||
`title` max length: 80 characters.
|
`title` max length: 80 characters.
|
||||||
|
|
||||||
|
### Project config (`config.yml` in the task folder)
|
||||||
|
|
||||||
|
Default layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.xflow/
|
||||||
|
config.yml # project tags catalog
|
||||||
|
fix-login.xflow.md
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .xflow/config.yml
|
||||||
|
tags:
|
||||||
|
- bug
|
||||||
|
- frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
If `config.yml` exists, tags on a task that are **not** in the catalog show as
|
||||||
|
warnings. Hover `tags` → **Edit tags…** / **Add tag to project**.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
- Search title/body; status/priority chips; group none/status/priority
|
- Search title/body; status/priority chips; group none/status/priority
|
||||||
|
|||||||
+22
-1
@@ -15,7 +15,8 @@ extension.
|
|||||||
- **Dashboard** — список + описание задачи (фильтры, scope, sort, group)
|
- **Dashboard** — список + описание задачи (фильтры, scope, sort, group)
|
||||||
- **Kanban** — колонки по статусу, drag-and-drop, create в колонке
|
- **Kanban** — колонки по статусу, drag-and-drop, create в колонке
|
||||||
- **Команды** — create / open / change status / delete
|
- **Команды** — create / open / change status / delete
|
||||||
- **In-editor** — lint frontmatter + Quick Pick status/priority (decorations)
|
- **In-editor** — lint frontmatter + status/priority/tags/assignee
|
||||||
|
- **Теги проекта** — каталог в `config.yml` папки задач
|
||||||
- **Два хранилища** — проектное (в workspace) и глобальное (общая папка на
|
- **Два хранилища** — проектное (в workspace) и глобальное (общая папка на
|
||||||
машине)
|
машине)
|
||||||
|
|
||||||
@@ -119,6 +120,26 @@ updated: 2026-07-12T10:00:00.000Z
|
|||||||
Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug).
|
Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug).
|
||||||
Макс. длина `title`: 80 символов.
|
Макс. длина `title`: 80 символов.
|
||||||
|
|
||||||
|
### Конфиг проекта (`config.yml` в папке задач)
|
||||||
|
|
||||||
|
По умолчанию:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.xflow/
|
||||||
|
config.yml
|
||||||
|
fix-login.xflow.md
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .xflow/config.yml
|
||||||
|
tags:
|
||||||
|
- bug
|
||||||
|
- frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `config.yml` есть, теги задачи вне каталога — warning. Hover на `tags` →
|
||||||
|
**Edit tags…** / **Add tag to project**.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
- Поиск по title/body, chips status/priority, group none/status/priority
|
- Поиск по title/body, chips status/priority, group none/status/priority
|
||||||
|
|||||||
@@ -77,6 +77,14 @@
|
|||||||
"command": "xboctukFlow.setAssigneeMe",
|
"command": "xboctukFlow.setAssigneeMe",
|
||||||
"title": "XBOCTuK Flow: Set Assignee to Me"
|
"title": "XBOCTuK Flow: Set Assignee to Me"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "xboctukFlow.editTaskTags",
|
||||||
|
"title": "XBOCTuK Flow: Edit Tags (in file)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "xboctukFlow.addTagToProject",
|
||||||
|
"title": "XBOCTuK Flow: Add Tag to Project"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "xboctukFlow.openDashboard",
|
"command": "xboctukFlow.openDashboard",
|
||||||
"title": "XBOCTuK Flow: Open Dashboard",
|
"title": "XBOCTuK Flow: Open Dashboard",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { AppErrorVariant } from "@/error";
|
||||||
|
import { getProjectConfigPath } from "@/model/projectConfig";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { addProjectTag } from "./addProjectTag";
|
||||||
|
import { loadProjectConfig } from "./loadProjectConfig";
|
||||||
|
|
||||||
|
describe("addProjectTag", () => {
|
||||||
|
const folder = "/ws/.xflow";
|
||||||
|
let fs: InMemoryFileSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs = new InMemoryFileSystem();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates config.yml and adds the tag", async () => {
|
||||||
|
const result = await addProjectTag(fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: "bug",
|
||||||
|
});
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.added).toBe(true);
|
||||||
|
expect(result.value.config.tags).toEqual(["bug"]);
|
||||||
|
|
||||||
|
const loaded = await loadProjectConfig(fs, folder);
|
||||||
|
expect(loaded._unsafeUnwrap().exists).toBe(true);
|
||||||
|
expect(loaded._unsafeUnwrap().config.tags).toEqual(["bug"]);
|
||||||
|
expect(await fs.readFile(getProjectConfigPath(folder))).toContain("bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent for an existing tag", async () => {
|
||||||
|
await addProjectTag(fs, { folderPath: folder, tag: "bug" });
|
||||||
|
const result = await addProjectTag(fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: "bug",
|
||||||
|
});
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.added).toBe(false);
|
||||||
|
expect(result.value.config.tags).toEqual(["bug"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a new tag", async () => {
|
||||||
|
await addProjectTag(fs, { folderPath: folder, tag: "bug" });
|
||||||
|
const result = await addProjectTag(fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: "frontend",
|
||||||
|
});
|
||||||
|
expect(result._unsafeUnwrap().config.tags).toEqual(["bug", "frontend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims tag and rejects empty", async () => {
|
||||||
|
const empty = await addProjectTag(fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: " ",
|
||||||
|
});
|
||||||
|
expect(empty.isErr()).toBe(true);
|
||||||
|
if (empty.isOk()) return;
|
||||||
|
expect(empty.error.variant).toBe(AppErrorVariant.EMPTY_TAG);
|
||||||
|
|
||||||
|
const trimmed = await addProjectTag(fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: " docs ",
|
||||||
|
});
|
||||||
|
expect(trimmed._unsafeUnwrap().config.tags).toEqual(["docs"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await addProjectTag(fs, { tag: "bug" });
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.NO_FOLDER);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import {
|
||||||
|
getProjectConfigPath,
|
||||||
|
normalizeTag,
|
||||||
|
type ProjectConfig,
|
||||||
|
} from "@/model/projectConfig";
|
||||||
|
import { IFileSystem } from "@/ports";
|
||||||
|
import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig";
|
||||||
|
import { loadProjectConfig } from "./loadProjectConfig";
|
||||||
|
|
||||||
|
export type AddProjectTagInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
tag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddProjectTagValue = {
|
||||||
|
config: ProjectConfig;
|
||||||
|
/** True if the tag was newly added. */
|
||||||
|
added: boolean;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function addProjectTag(
|
||||||
|
fs: IFileSystem,
|
||||||
|
input: AddProjectTagInput,
|
||||||
|
): ResultAsync<AddProjectTagValue, AppError> {
|
||||||
|
if (input.folderPath === undefined || input.folderPath === "") {
|
||||||
|
return errAsync(appError(AppErrorVariant.NO_FOLDER));
|
||||||
|
}
|
||||||
|
if (input.tag === undefined) {
|
||||||
|
return errAsync(appError(AppErrorVariant.EMPTY_TAG));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = normalizeTag(input.tag);
|
||||||
|
if (!tag) {
|
||||||
|
return errAsync(appError(AppErrorVariant.EMPTY_TAG));
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = input.folderPath;
|
||||||
|
const path = getProjectConfigPath(folderPath);
|
||||||
|
|
||||||
|
return loadProjectConfig(fs, folderPath).andThen((loaded) => {
|
||||||
|
if (loaded.config.tags.includes(tag)) {
|
||||||
|
return okAsync({
|
||||||
|
config: loaded.config,
|
||||||
|
added: false,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: ProjectConfig = {
|
||||||
|
tags: [...loaded.config.tags, tag],
|
||||||
|
};
|
||||||
|
const content = serializeProjectConfig(config);
|
||||||
|
|
||||||
|
return ResultAsync.fromPromise(
|
||||||
|
fs.mkdir(folderPath).then(() => fs.writeFile(path, content)),
|
||||||
|
(error) =>
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: error instanceof Error ? error.message : "write failed",
|
||||||
|
}),
|
||||||
|
).map(() => ({
|
||||||
|
config,
|
||||||
|
added: true,
|
||||||
|
path,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { AppErrorVariant } from "@/error";
|
||||||
|
import { getProjectConfigPath } from "@/model/projectConfig";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { loadProjectConfig } from "./loadProjectConfig";
|
||||||
|
|
||||||
|
describe("loadProjectConfig", () => {
|
||||||
|
const folder = "/ws/.xflow";
|
||||||
|
let fs: InMemoryFileSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs = new InMemoryFileSystem();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns exists:false and empty tags when config.yml is missing", async () => {
|
||||||
|
const result = await loadProjectConfig(fs, folder);
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.exists).toBe(false);
|
||||||
|
expect(result.value.config.tags).toEqual([]);
|
||||||
|
expect(result.value.path).toBe(getProjectConfigPath(folder));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads tags when config.yml exists", async () => {
|
||||||
|
await fs.mkdir(folder);
|
||||||
|
await fs.writeFile(
|
||||||
|
getProjectConfigPath(folder),
|
||||||
|
"tags:\n - bug\n - frontend\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loadProjectConfig(fs, folder);
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.exists).toBe(true);
|
||||||
|
expect(result.value.config.tags).toEqual(["bug", "frontend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder path", async () => {
|
||||||
|
const result = await loadProjectConfig(fs, undefined);
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.NO_FOLDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid config content", async () => {
|
||||||
|
await fs.mkdir(folder);
|
||||||
|
await fs.writeFile(getProjectConfigPath(folder), "tags: [\n");
|
||||||
|
|
||||||
|
const result = await loadProjectConfig(fs, folder);
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import {
|
||||||
|
EMPTY_PROJECT_CONFIG,
|
||||||
|
getProjectConfigPath,
|
||||||
|
type ProjectConfig,
|
||||||
|
} from "@/model/projectConfig";
|
||||||
|
import { IFileSystem } from "@/ports";
|
||||||
|
import { parseProjectConfig } from "@/projectConfig/parseProjectConfig";
|
||||||
|
|
||||||
|
export type LoadProjectConfigValue = {
|
||||||
|
config: ProjectConfig;
|
||||||
|
/** False when config.yml is missing (catalog checks should be skipped). */
|
||||||
|
exists: boolean;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadProjectConfig(
|
||||||
|
fs: IFileSystem,
|
||||||
|
folderPath: string | undefined,
|
||||||
|
): ResultAsync<LoadProjectConfigValue, AppError> {
|
||||||
|
if (folderPath === undefined || folderPath === "") {
|
||||||
|
return errAsync(appError(AppErrorVariant.NO_FOLDER));
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getProjectConfigPath(folderPath);
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(
|
||||||
|
fs.readFile(path).then(
|
||||||
|
(content) => ({ found: true as const, content }),
|
||||||
|
() => ({ found: false as const }),
|
||||||
|
),
|
||||||
|
).andThen((read) => {
|
||||||
|
if (!read.found) {
|
||||||
|
return okAsync({
|
||||||
|
config: { ...EMPTY_PROJECT_CONFIG },
|
||||||
|
exists: false,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseProjectConfig(read.content);
|
||||||
|
if (parsed.isErr()) {
|
||||||
|
return errAsync(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return okAsync({
|
||||||
|
config: parsed.value,
|
||||||
|
exists: true,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export const AppErrorVariant = {
|
|||||||
NOT_FOUND: "not-found",
|
NOT_FOUND: "not-found",
|
||||||
INVALID_MESSAGE: "invalid-message",
|
INVALID_MESSAGE: "invalid-message",
|
||||||
INVALID_TASK_FILE: "invalid-task-file",
|
INVALID_TASK_FILE: "invalid-task-file",
|
||||||
|
INVALID_PROJECT_CONFIG: "invalid-project-config",
|
||||||
|
EMPTY_TAG: "empty-tag",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppErrorVariant =
|
export type AppErrorVariant =
|
||||||
@@ -31,6 +33,8 @@ const APP_ERROR_MESSAGES: Record<AppErrorVariant, string> = {
|
|||||||
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
|
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
|
||||||
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
|
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
|
||||||
[AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}",
|
[AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}",
|
||||||
|
[AppErrorVariant.INVALID_PROJECT_CONFIG]: "Invalid project config: {detail}",
|
||||||
|
[AppErrorVariant.EMPTY_TAG]: "Tag is empty",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMessage(
|
export function formatMessage(
|
||||||
|
|||||||
+2
-1
@@ -48,13 +48,14 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
statusBarItem.show();
|
statusBarItem.show();
|
||||||
context.subscriptions.push(statusBarItem);
|
context.subscriptions.push(statusBarItem);
|
||||||
|
|
||||||
registerTaskDiagnostics(context);
|
registerTaskDiagnostics(context, { fs: fileSystem, config });
|
||||||
registerTaskDecorations(context);
|
registerTaskDecorations(context);
|
||||||
|
|
||||||
registerTaskCommands(context, {
|
registerTaskCommands(context, {
|
||||||
repo,
|
repo,
|
||||||
config,
|
config,
|
||||||
tree,
|
tree,
|
||||||
|
fs: fileSystem,
|
||||||
onTasksMutated: () => {
|
onTasksMutated: () => {
|
||||||
TaskDashboardPanel.refreshIfOpen();
|
TaskDashboardPanel.refreshIfOpen();
|
||||||
TaskKanbanPanel.refreshIfOpen();
|
TaskKanbanPanel.refreshIfOpen();
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { findFieldRange, replaceFrontmatterField } from "./frontmatterFields";
|
import {
|
||||||
|
findFieldRange,
|
||||||
|
formatTagsYamlValue,
|
||||||
|
replaceFrontmatterField,
|
||||||
|
setFrontmatterTags,
|
||||||
|
} from "./frontmatterFields";
|
||||||
|
|
||||||
const sample = `---
|
const sample = `---
|
||||||
id: abc
|
id: abc
|
||||||
title: Hello
|
title: Hello
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
tags: [old]
|
||||||
---
|
---
|
||||||
|
|
||||||
Body
|
Body
|
||||||
@@ -47,3 +53,26 @@ describe("findFieldRange", () => {
|
|||||||
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo");
|
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("formatTagsYamlValue", () => {
|
||||||
|
it("formats empty and simple lists", () => {
|
||||||
|
expect(formatTagsYamlValue([])).toBe("[]");
|
||||||
|
expect(formatTagsYamlValue(["bug", "frontend"])).toBe(
|
||||||
|
"[bug, frontend]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes tags with spaces", () => {
|
||||||
|
expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setFrontmatterTags", () => {
|
||||||
|
it("replaces the tags line", () => {
|
||||||
|
const result = setFrontmatterTags(sample, ["bug", "docs"]);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.content).toContain("tags: [bug, docs]");
|
||||||
|
expect(result.content).not.toContain("tags: [old]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { yamlInlineScalar } from "@/utils/yamlScalar";
|
||||||
import {
|
import {
|
||||||
extractFrontmatterBlock,
|
extractFrontmatterBlock,
|
||||||
findFieldLineRange,
|
findFieldLineRange,
|
||||||
@@ -12,6 +13,21 @@ export type ReplaceFrontmatterFieldResult =
|
|||||||
| { ok: true; content: string; range: TextRange }
|
| { ok: true; content: string; range: TextRange }
|
||||||
| { ok: false; reason: "no-frontmatter" | "no-field" };
|
| { ok: false; reason: "no-frontmatter" | "no-field" };
|
||||||
|
|
||||||
|
/** Inline YAML list for frontmatter `tags: […]`. */
|
||||||
|
export function formatTagsYamlValue(tags: string[]): string {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFrontmatterTags(
|
||||||
|
content: string,
|
||||||
|
tags: string[],
|
||||||
|
): ReplaceFrontmatterFieldResult {
|
||||||
|
return replaceFrontmatterField(content, "tags", formatTagsYamlValue(tags));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a scalar frontmatter field value on its line.
|
* Replace a scalar frontmatter field value on its line.
|
||||||
* Does not re-serialize YAML; only rewrites the value after `:`.
|
* Does not re-serialize YAML; only rewrites the value after `:`.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseTaskTags } from "./parseTaskTags";
|
||||||
|
|
||||||
|
const sample = `---
|
||||||
|
id: "a"
|
||||||
|
title: T
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
tags: [bug, frontend]
|
||||||
|
assignee: ""
|
||||||
|
created: 2026-01-01T00:00:00.000Z
|
||||||
|
updated: 2026-01-01T00:00:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
body
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("parseTaskTags", () => {
|
||||||
|
it("reads tags from frontmatter", () => {
|
||||||
|
expect(parseTaskTags(sample)).toEqual(["bug", "frontend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when tags missing", () => {
|
||||||
|
const content = sample.replace("tags: [bug, frontend]\n", "");
|
||||||
|
expect(parseTaskTags(content)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for invalid content", () => {
|
||||||
|
expect(parseTaskTags("not a task")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import { extractFrontmatterBlock } from "./frontmatterBlock";
|
||||||
|
|
||||||
|
/** Read current tags array from task file content (empty if missing/invalid). */
|
||||||
|
export function parseTaskTags(content: string): string[] {
|
||||||
|
const block = extractFrontmatterBlock(content);
|
||||||
|
if (!block || block.closeLine < 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = yaml.load(block.yaml);
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const tags = (data as Record<string, unknown>).tags;
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return tags.filter((t): t is string => typeof t === "string");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,4 +93,38 @@ title: [unterminated
|
|||||||
const issues = validateFrontmatter(content);
|
const issues = validateFrontmatter(content);
|
||||||
expect(issues.some((i) => /yaml/i.test(i.message))).toBe(true);
|
expect(issues.some((i) => /yaml/i.test(i.message))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not warn about unknown tags when knownTags is omitted", () => {
|
||||||
|
const issues = validateFrontmatter(valid);
|
||||||
|
expect(issues.filter((i) => i.field === "tags")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when a tag is not in knownTags", () => {
|
||||||
|
const content = valid.replace("tags: [bug]", "tags: [bug, nope]");
|
||||||
|
const issues = validateFrontmatter(content, { knownTags: ["bug"] });
|
||||||
|
const unknown = issues.filter(
|
||||||
|
(i) => i.field === "tags" && i.severity === "warning",
|
||||||
|
);
|
||||||
|
expect(unknown).toHaveLength(1);
|
||||||
|
expect(unknown[0].message).toMatch(/nope/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when all tags are known", () => {
|
||||||
|
const issues = validateFrontmatter(valid, {
|
||||||
|
knownTags: ["bug", "frontend"],
|
||||||
|
});
|
||||||
|
expect(issues.filter((i) => i.severity === "warning")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns for every tag when knownTags is empty array (config exists)", () => {
|
||||||
|
const issues = validateFrontmatter(valid, { knownTags: [] });
|
||||||
|
expect(
|
||||||
|
issues.some(
|
||||||
|
(i) =>
|
||||||
|
i.field === "tags" &&
|
||||||
|
i.severity === "warning" &&
|
||||||
|
/bug/i.test(i.message),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,11 +47,50 @@ function fieldRangeOrBlock(
|
|||||||
return findFieldRange(content, key) ?? blockRange;
|
return findFieldRange(content, key) ?? blockRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort range of a tag token inside the tags field value. */
|
||||||
|
export function findTagTokenRange(
|
||||||
|
content: string,
|
||||||
|
tag: string,
|
||||||
|
): TextRange | undefined {
|
||||||
|
const fieldRange = findFieldRange(content, "tags");
|
||||||
|
if (!fieldRange) return undefined;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const line = lines[fieldRange.startLine] ?? "";
|
||||||
|
const value = line.slice(fieldRange.startCol);
|
||||||
|
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp(
|
||||||
|
`(?<![\\w-])(?:"${escaped}"|'${escaped}'|${escaped})(?![\\w-])`,
|
||||||
|
);
|
||||||
|
const match = re.exec(value);
|
||||||
|
if (!match || match.index === undefined) {
|
||||||
|
return fieldRange;
|
||||||
|
}
|
||||||
|
const startCol = fieldRange.startCol + match.index;
|
||||||
|
return {
|
||||||
|
startLine: fieldRange.startLine,
|
||||||
|
startCol,
|
||||||
|
endLine: fieldRange.startLine,
|
||||||
|
endCol: startCol + match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateFrontmatterOptions = {
|
||||||
|
/**
|
||||||
|
* When set (including empty array), tags not in this list produce warnings.
|
||||||
|
* When undefined, catalog checks are skipped (no project config file).
|
||||||
|
*/
|
||||||
|
knownTags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate task file frontmatter. Pure — no vscode.
|
* Validate task file frontmatter. Pure — no vscode.
|
||||||
* Ranges are 0-based line/column.
|
* Ranges are 0-based line/column.
|
||||||
*/
|
*/
|
||||||
export function validateFrontmatter(content: string): FrontmatterIssue[] {
|
export function validateFrontmatter(
|
||||||
|
content: string,
|
||||||
|
options: ValidateFrontmatterOptions = {},
|
||||||
|
): FrontmatterIssue[] {
|
||||||
const issues: FrontmatterIssue[] = [];
|
const issues: FrontmatterIssue[] = [];
|
||||||
const block = extractFrontmatterBlock(content);
|
const block = extractFrontmatterBlock(content);
|
||||||
|
|
||||||
@@ -209,6 +248,20 @@ export function validateFrontmatter(content: string): FrontmatterIssue[] {
|
|||||||
severity: "error",
|
severity: "error",
|
||||||
range: fieldRangeOrBlock(content, "tags", blockRange),
|
range: fieldRangeOrBlock(content, "tags", blockRange),
|
||||||
});
|
});
|
||||||
|
} else if (options.knownTags !== undefined) {
|
||||||
|
const known = new Set(options.knownTags);
|
||||||
|
for (const tag of record.tags as string[]) {
|
||||||
|
if (!known.has(tag)) {
|
||||||
|
issues.push({
|
||||||
|
field: "tags",
|
||||||
|
message: `Unknown tag "${tag}"; add it to project config or pick from catalog`,
|
||||||
|
severity: "warning",
|
||||||
|
range:
|
||||||
|
findTagTokenRange(content, tag) ??
|
||||||
|
fieldRangeOrBlock(content, "tags", blockRange),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
EMPTY_PROJECT_CONFIG,
|
||||||
|
getProjectConfigPath,
|
||||||
|
normalizeTag,
|
||||||
|
TASK_FOLDER_CONFIG_FILENAME,
|
||||||
|
} from "./projectConfig";
|
||||||
|
|
||||||
|
describe("projectConfig model", () => {
|
||||||
|
it("uses config.yml as the filename inside the task folder", () => {
|
||||||
|
expect(TASK_FOLDER_CONFIG_FILENAME).toBe("config.yml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins folder path with config.yml", () => {
|
||||||
|
expect(getProjectConfigPath("/ws/.xflow")).toBe("/ws/.xflow/config.yml");
|
||||||
|
expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(
|
||||||
|
/config\.yml$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty config has no tags", () => {
|
||||||
|
expect(EMPTY_PROJECT_CONFIG).toEqual({ tags: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizeTag trims and rejects empty", () => {
|
||||||
|
expect(normalizeTag(" bug ")).toBe("bug");
|
||||||
|
expect(normalizeTag(" ")).toBeUndefined();
|
||||||
|
expect(normalizeTag("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Config file name inside a task folder (project or global). */
|
||||||
|
export const TASK_FOLDER_CONFIG_FILENAME = "config.yml";
|
||||||
|
|
||||||
|
export type ProjectConfig = {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Absolute/joined path to config.yml in a task folder. */
|
||||||
|
export function getProjectConfigPath(folderPath: string): string {
|
||||||
|
const base = folderPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
|
return `${base}/${TASK_FOLDER_CONFIG_FILENAME}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a tag: trim. Empty after trim → undefined. */
|
||||||
|
export function normalizeTag(value: string): string | undefined {
|
||||||
|
const tag = value.trim();
|
||||||
|
return tag.length > 0 ? tag : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AppErrorVariant } from "@/error";
|
||||||
|
import { parseProjectConfig } from "./parseProjectConfig";
|
||||||
|
|
||||||
|
describe("parseProjectConfig", () => {
|
||||||
|
it("parses tags list", () => {
|
||||||
|
const result = parseProjectConfig("tags:\n - bug\n - frontend\n");
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.tags).toEqual(["bug", "frontend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty / missing tags", () => {
|
||||||
|
expect(parseProjectConfig("")._unsafeUnwrap().tags).toEqual([]);
|
||||||
|
expect(parseProjectConfig("tags: []\n")._unsafeUnwrap().tags).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and trims tags", () => {
|
||||||
|
const result = parseProjectConfig(
|
||||||
|
"tags:\n - bug\n - bug \n - frontend\n",
|
||||||
|
);
|
||||||
|
expect(result._unsafeUnwrap().tags).toEqual(["bug", "frontend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid YAML", () => {
|
||||||
|
const result = parseProjectConfig("tags: [\n");
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string tags", () => {
|
||||||
|
const result = parseProjectConfig("tags:\n - 1\n");
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import { err, ok, Result } from "neverthrow";
|
||||||
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import {
|
||||||
|
EMPTY_PROJECT_CONFIG,
|
||||||
|
normalizeTag,
|
||||||
|
type ProjectConfig,
|
||||||
|
} from "@/model/projectConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse task-folder config.yml content.
|
||||||
|
* Accepts missing/empty tags → [].
|
||||||
|
*/
|
||||||
|
export function parseProjectConfig(
|
||||||
|
content: string,
|
||||||
|
): Result<ProjectConfig, AppError> {
|
||||||
|
if (content.trim() === "") {
|
||||||
|
return ok({ ...EMPTY_PROJECT_CONFIG });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = yaml.load(content);
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : "invalid YAML";
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { detail }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return ok({ ...EMPTY_PROJECT_CONFIG });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: "root must be a mapping",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (record.tags === undefined) {
|
||||||
|
return ok({ tags: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(record.tags)) {
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: "tags must be an array of strings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of record.tags) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
return err(
|
||||||
|
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
|
||||||
|
detail: "tags must be an array of strings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tag = normalizeTag(item);
|
||||||
|
if (!tag || seen.has(tag)) continue;
|
||||||
|
seen.add(tag);
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ tags });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseProjectConfig } from "./parseProjectConfig";
|
||||||
|
import { serializeProjectConfig } from "./serializeProjectConfig";
|
||||||
|
|
||||||
|
describe("serializeProjectConfig", () => {
|
||||||
|
it("round-trips tags", () => {
|
||||||
|
const yaml = serializeProjectConfig({ tags: ["bug", "docs"] });
|
||||||
|
expect(yaml).toContain("bug");
|
||||||
|
expect(yaml).toContain("docs");
|
||||||
|
const parsed = parseProjectConfig(yaml);
|
||||||
|
expect(parsed._unsafeUnwrap().tags).toEqual(["bug", "docs"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes empty tags", () => {
|
||||||
|
const yaml = serializeProjectConfig({ tags: [] });
|
||||||
|
const parsed = parseProjectConfig(yaml);
|
||||||
|
expect(parsed._unsafeUnwrap().tags).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import type { ProjectConfig } from "@/model/projectConfig";
|
||||||
|
|
||||||
|
export function serializeProjectConfig(config: ProjectConfig): string {
|
||||||
|
return yaml
|
||||||
|
.dump(
|
||||||
|
{ tags: config.tags },
|
||||||
|
{
|
||||||
|
lineWidth: -1,
|
||||||
|
forceQuotes: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.trimEnd()
|
||||||
|
.concat("\n");
|
||||||
|
}
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import { addProjectTag } from "@/commands/addProjectTag";
|
||||||
|
import { loadProjectConfig } from "@/commands/loadProjectConfig";
|
||||||
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
import {
|
import {
|
||||||
findFieldRange,
|
findFieldRange,
|
||||||
|
formatTagsYamlValue,
|
||||||
replaceFrontmatterField,
|
replaceFrontmatterField,
|
||||||
|
setFrontmatterTags,
|
||||||
} from "@/frontmatter/frontmatterFields";
|
} from "@/frontmatter/frontmatterFields";
|
||||||
|
import { parseTaskTags } from "@/frontmatter/parseTaskTags";
|
||||||
|
import { normalizeTag } from "@/model/projectConfig";
|
||||||
import {
|
import {
|
||||||
isTaskFileName,
|
isTaskFileName,
|
||||||
TASK_FILE_EXTENSION,
|
TASK_FILE_EXTENSION,
|
||||||
TASK_LANGUAGE_ID,
|
TASK_LANGUAGE_ID,
|
||||||
} from "@/model/taskFile";
|
} from "@/model/taskFile";
|
||||||
|
import { IConfigProvider, IFileSystem } from "@/ports";
|
||||||
import { getGitUserName, yamlInlineScalar } from "./getGitUserName";
|
import { getGitUserName, yamlInlineScalar } from "./getGitUserName";
|
||||||
|
import { presentError } from "./presentError";
|
||||||
|
import { resolveTaskFolderForPath } from "./resolveTaskFolder";
|
||||||
import {
|
import {
|
||||||
TASK_PRIORITY_LABELS,
|
TASK_PRIORITY_LABELS,
|
||||||
TASK_PRIORITY_ORDER,
|
TASK_PRIORITY_ORDER,
|
||||||
@@ -16,10 +26,16 @@ import {
|
|||||||
TASK_STATUS_ORDER,
|
TASK_STATUS_ORDER,
|
||||||
} from "./taskLabels";
|
} from "./taskLabels";
|
||||||
|
|
||||||
|
export type FrontmatterEditDeps = {
|
||||||
|
fs: IFileSystem;
|
||||||
|
config: IConfigProvider;
|
||||||
|
};
|
||||||
|
|
||||||
export const EditableField = {
|
export const EditableField = {
|
||||||
STATUS: "status",
|
STATUS: "status",
|
||||||
PRIORITY: "priority",
|
PRIORITY: "priority",
|
||||||
ASSIGNEE: "assignee",
|
ASSIGNEE: "assignee",
|
||||||
|
TAGS: "tags",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EditableField = (typeof EditableField)[keyof typeof EditableField];
|
export type EditableField = (typeof EditableField)[keyof typeof EditableField];
|
||||||
@@ -151,5 +167,253 @@ export async function runSetAssigneeMe(uriArg?: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await applyFieldValue(document, EditableField.ASSIGNEE, yamlInlineScalar(name));
|
await applyFieldValue(
|
||||||
|
document,
|
||||||
|
EditableField.ASSIGNEE,
|
||||||
|
yamlInlineScalar(name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTaskFolder(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
config: IConfigProvider,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const locations = resolveTaskLocations(config);
|
||||||
|
return resolveTaskFolderForPath(document.fileName, locations);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTags(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
tags: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const text = document.getText();
|
||||||
|
const result = setFrontmatterTags(text, tags);
|
||||||
|
if (!result.ok) {
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
result.reason === "no-field"
|
||||||
|
? 'Field "tags" not found in frontmatter'
|
||||||
|
: "No YAML frontmatter in this file",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueRange = findFieldRange(text, EditableField.TAGS);
|
||||||
|
const value = formatTagsYamlValue(tags);
|
||||||
|
if (!valueRange) {
|
||||||
|
const full = new vscode.Range(
|
||||||
|
document.positionAt(0),
|
||||||
|
document.positionAt(text.length),
|
||||||
|
);
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.replace(document.uri, full, result.content);
|
||||||
|
return vscode.workspace.applyEdit(edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = new vscode.Range(
|
||||||
|
valueRange.startLine,
|
||||||
|
valueRange.startCol,
|
||||||
|
valueRange.endLine,
|
||||||
|
valueRange.endCol,
|
||||||
|
);
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.replace(document.uri, range, value);
|
||||||
|
return vscode.workspace.applyEdit(edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select tags from project config.
|
||||||
|
*
|
||||||
|
* - Type a name + Enter → add tag (selected), stay on Quick Pick
|
||||||
|
* - Enter on empty field → apply selection to task + config
|
||||||
|
* - Escape → cancel
|
||||||
|
*/
|
||||||
|
export async function runEditTaskTags(
|
||||||
|
deps: FrontmatterEditDeps,
|
||||||
|
uriArg?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const document = await resolveDocument(uriArg);
|
||||||
|
if (!document) {
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
`Open a ${TASK_FILE_EXTENSION} task file first`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await resolveTaskFolder(document, deps.config);
|
||||||
|
if (!folder) {
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
"Task file is outside a configured task folder",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadProjectConfig(deps.fs, folder);
|
||||||
|
if (loaded.isErr()) {
|
||||||
|
presentError(loaded.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = [...loaded.value.config.tags];
|
||||||
|
const current = parseTaskTags(document.getText());
|
||||||
|
|
||||||
|
type TagItem = vscode.QuickPickItem & { tag: string };
|
||||||
|
|
||||||
|
const selected = await new Promise<string[] | undefined>((resolve) => {
|
||||||
|
const qp = vscode.window.createQuickPick<TagItem>();
|
||||||
|
qp.canSelectMany = true;
|
||||||
|
qp.ignoreFocusOut = true;
|
||||||
|
qp.placeholder =
|
||||||
|
"Select tags · type name + Enter to add · empty Enter to apply · Esc to cancel";
|
||||||
|
qp.matchOnDescription = true;
|
||||||
|
|
||||||
|
const extraTags: string[] = [];
|
||||||
|
let selectedTags = new Set(current);
|
||||||
|
let accepted = false;
|
||||||
|
let syncingSelection = false;
|
||||||
|
|
||||||
|
const rebuild = (): void => {
|
||||||
|
const items: TagItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const tag of [...catalog, ...current, ...extraTags]) {
|
||||||
|
if (seen.has(tag)) continue;
|
||||||
|
seen.add(tag);
|
||||||
|
items.push({
|
||||||
|
label: tag,
|
||||||
|
description: catalog.includes(tag)
|
||||||
|
? undefined
|
||||||
|
: "will add to config",
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
syncingSelection = true;
|
||||||
|
qp.items = items;
|
||||||
|
qp.selectedItems = items.filter((item) => selectedTags.has(item.tag));
|
||||||
|
syncingSelection = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild();
|
||||||
|
|
||||||
|
qp.onDidChangeSelection((items) => {
|
||||||
|
if (syncingSelection) return;
|
||||||
|
selectedTags = new Set(items.map((item) => item.tag));
|
||||||
|
});
|
||||||
|
|
||||||
|
qp.onDidAccept(() => {
|
||||||
|
const typed = normalizeTag(qp.value);
|
||||||
|
if (typed) {
|
||||||
|
if (!extraTags.includes(typed) && !catalog.includes(typed)) {
|
||||||
|
extraTags.push(typed);
|
||||||
|
}
|
||||||
|
selectedTags.add(typed);
|
||||||
|
qp.value = "";
|
||||||
|
rebuild();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted = true;
|
||||||
|
const tags = [...selectedTags];
|
||||||
|
qp.hide();
|
||||||
|
qp.dispose();
|
||||||
|
resolve(tags);
|
||||||
|
});
|
||||||
|
|
||||||
|
qp.onDidHide(() => {
|
||||||
|
if (!accepted) {
|
||||||
|
qp.dispose();
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qp.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected === undefined) return;
|
||||||
|
|
||||||
|
const catalogSet = new Set(catalog);
|
||||||
|
for (const tag of selected) {
|
||||||
|
if (catalogSet.has(tag)) continue;
|
||||||
|
const added = await addProjectTag(deps.fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
if (added.isErr()) {
|
||||||
|
presentError(added.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catalogSet.add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await applyTags(document, selected);
|
||||||
|
if (ok) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
selected.length ? `Tags: ${selected.join(", ")}` : "Tags cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add unknown task tags into the task-folder config.yml. */
|
||||||
|
export async function runAddTagToProject(
|
||||||
|
deps: FrontmatterEditDeps,
|
||||||
|
uriArg?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const document = await resolveDocument(uriArg);
|
||||||
|
if (!document) {
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
`Open a ${TASK_FILE_EXTENSION} task file first`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await resolveTaskFolder(document, deps.config);
|
||||||
|
if (!folder) {
|
||||||
|
void vscode.window.showErrorMessage(
|
||||||
|
"Task file is outside a configured task folder",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadProjectConfig(deps.fs, folder);
|
||||||
|
if (loaded.isErr()) {
|
||||||
|
presentError(loaded.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = new Set(loaded.value.config.tags);
|
||||||
|
const current = parseTaskTags(document.getText());
|
||||||
|
const unknown = current.filter((tag) => !known.has(tag));
|
||||||
|
|
||||||
|
if (unknown.length === 0) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
"All task tags are already in project config",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = await vscode.window.showQuickPick(
|
||||||
|
unknown.map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
tag,
|
||||||
|
picked: true,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
canPickMany: true,
|
||||||
|
placeHolder: "Add to project config",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!picked || picked.length === 0) return;
|
||||||
|
|
||||||
|
for (const item of picked) {
|
||||||
|
const result = await addProjectTag(deps.fs, {
|
||||||
|
folderPath: folder,
|
||||||
|
tag: item.tag,
|
||||||
|
});
|
||||||
|
if (result.isErr()) {
|
||||||
|
presentError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
`Added to project config: ${picked.map((p) => p.tag).join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,4 @@ export async function getGitUserName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quote a YAML scalar for a single-line frontmatter value. */
|
export { yamlInlineScalar } from "@/utils/yamlScalar";
|
||||||
export function yamlInlineScalar(value: string): string {
|
|
||||||
if (value === "") {
|
|
||||||
return '""';
|
|
||||||
}
|
|
||||||
// Safe unquoted token (no spaces / special YAML chars).
|
|
||||||
if (/^[A-Za-z0-9_./+-]+$/.test(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { TaskScope } from "@/model/taskLocation";
|
||||||
|
import { resolveTaskFolderForPath } from "./resolveTaskFolder";
|
||||||
|
|
||||||
|
describe("resolveTaskFolderForPath", () => {
|
||||||
|
const locations = [
|
||||||
|
{ scope: TaskScope.PROJECT, folderPath: "/ws/.xflow" },
|
||||||
|
{ scope: TaskScope.GLOBAL, folderPath: "/global/tasks" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("matches project task file", () => {
|
||||||
|
expect(
|
||||||
|
resolveTaskFolderForPath("/ws/.xflow/fix-login.xflow.md", locations),
|
||||||
|
).toBe("/ws/.xflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches global task file", () => {
|
||||||
|
expect(
|
||||||
|
resolveTaskFolderForPath("/global/tasks/note.xflow.md", locations),
|
||||||
|
).toBe("/global/tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined outside task folders", () => {
|
||||||
|
expect(resolveTaskFolderForPath("/ws/src/app.ts", locations)).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers longer matching prefix", () => {
|
||||||
|
const nested = [
|
||||||
|
{ scope: TaskScope.PROJECT, folderPath: "/ws" },
|
||||||
|
{ scope: TaskScope.GLOBAL, folderPath: "/ws/.xflow" },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveTaskFolderForPath("/ws/.xflow/a.xflow.md", nested),
|
||||||
|
).toBe("/ws/.xflow");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
import { TaskLocation } from "@/model/taskLocation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the task folder (project/global path) that contains `filePath`.
|
||||||
|
* Longest matching prefix wins.
|
||||||
|
*/
|
||||||
|
export function resolveTaskFolderForPath(
|
||||||
|
filePath: string,
|
||||||
|
locations: TaskLocation[],
|
||||||
|
): string | undefined {
|
||||||
|
const normalized = path.resolve(filePath).replace(/\\/g, "/");
|
||||||
|
let best: string | undefined;
|
||||||
|
let bestLen = -1;
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
const folder = path.resolve(location.folderPath).replace(/\\/g, "/");
|
||||||
|
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
|
||||||
|
if (
|
||||||
|
(normalized === folder || normalized.startsWith(prefix)) &&
|
||||||
|
folder.length > bestLen
|
||||||
|
) {
|
||||||
|
best = location.folderPath;
|
||||||
|
bestLen = folder.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
|||||||
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
||||||
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||||
import {
|
import {
|
||||||
|
runAddTagToProject,
|
||||||
runEditTaskPriority,
|
runEditTaskPriority,
|
||||||
runEditTaskStatus,
|
runEditTaskStatus,
|
||||||
|
runEditTaskTags,
|
||||||
runSetAssigneeMe,
|
runSetAssigneeMe,
|
||||||
} from "./editFrontmatterField";
|
} from "./editFrontmatterField";
|
||||||
import { presentError } from "./presentError";
|
import { presentError } from "./presentError";
|
||||||
@@ -27,6 +29,7 @@ export type TaskCommandDeps = {
|
|||||||
repo: ITaskRepository;
|
repo: ITaskRepository;
|
||||||
config: IConfigProvider;
|
config: IConfigProvider;
|
||||||
tree: TaskTreeProvider;
|
tree: TaskTreeProvider;
|
||||||
|
fs: import("@/ports").IFileSystem;
|
||||||
/** Refresh dashboard (and any other listeners) after task mutations. */
|
/** Refresh dashboard (and any other listeners) after task mutations. */
|
||||||
onTasksMutated?: () => void;
|
onTasksMutated?: () => void;
|
||||||
};
|
};
|
||||||
@@ -257,6 +260,16 @@ export function registerTaskCommands(
|
|||||||
"xboctukFlow.setAssigneeMe",
|
"xboctukFlow.setAssigneeMe",
|
||||||
(uri?: string) => runSetAssigneeMe(uri),
|
(uri?: string) => runSetAssigneeMe(uri),
|
||||||
),
|
),
|
||||||
|
vscode.commands.registerCommand(
|
||||||
|
"xboctukFlow.editTaskTags",
|
||||||
|
(uri?: string) =>
|
||||||
|
runEditTaskTags({ fs: deps.fs, config: deps.config }, uri),
|
||||||
|
),
|
||||||
|
vscode.commands.registerCommand(
|
||||||
|
"xboctukFlow.addTagToProject",
|
||||||
|
(uri?: string) =>
|
||||||
|
runAddTagToProject({ fs: deps.fs, config: deps.config }, uri),
|
||||||
|
),
|
||||||
vscode.commands.registerCommand("xboctukFlow.openDashboard", () =>
|
vscode.commands.registerCommand("xboctukFlow.openDashboard", () =>
|
||||||
runOpenDashboard(deps),
|
runOpenDashboard(deps),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ function hoverAssigneeMe(document: vscode.TextDocument): vscode.MarkdownString {
|
|||||||
return md;
|
return md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hoverTags(document: vscode.TextDocument): vscode.MarkdownString {
|
||||||
|
const uri = document.uri.toString();
|
||||||
|
const md = new vscode.MarkdownString(
|
||||||
|
[
|
||||||
|
`[$(edit) Edit tags…](${commandUri("xboctukFlow.editTaskTags", [uri])})`,
|
||||||
|
`[$(repo-push) Add tag to project](${commandUri("xboctukFlow.addTagToProject", [uri])})`,
|
||||||
|
].join(" · "),
|
||||||
|
);
|
||||||
|
md.isTrusted = true;
|
||||||
|
md.supportThemeIcons = true;
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerTaskDecorations(
|
export function registerTaskDecorations(
|
||||||
context: vscode.ExtensionContext,
|
context: vscode.ExtensionContext,
|
||||||
): void {
|
): void {
|
||||||
@@ -106,6 +119,25 @@ export function registerTaskDecorations(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagsRange = findFieldRange(text, EditableField.TAGS);
|
||||||
|
if (tagsRange) {
|
||||||
|
options.push({
|
||||||
|
range: new vscode.Range(
|
||||||
|
tagsRange.startLine,
|
||||||
|
tagsRange.startCol,
|
||||||
|
tagsRange.endLine,
|
||||||
|
tagsRange.endCol,
|
||||||
|
),
|
||||||
|
hoverMessage: hoverTags(editor.document),
|
||||||
|
renderOptions: {
|
||||||
|
after: {
|
||||||
|
contentText: " ✎ tags",
|
||||||
|
color: new vscode.ThemeColor("textLink.foreground"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
editor.setDecorations(decorationType, options);
|
editor.setDecorations(decorationType, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
|
import { loadProjectConfig } from "@/commands/loadProjectConfig";
|
||||||
import {
|
import {
|
||||||
validateFrontmatter,
|
validateFrontmatter,
|
||||||
type FrontmatterIssue,
|
type FrontmatterIssue,
|
||||||
} from "@/frontmatter/validateFrontmatter";
|
} from "@/frontmatter/validateFrontmatter";
|
||||||
|
import { TASK_FOLDER_CONFIG_FILENAME } from "@/model/projectConfig";
|
||||||
import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile";
|
import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile";
|
||||||
|
import { IConfigProvider, IFileSystem } from "@/ports";
|
||||||
|
import { resolveTaskFolderForPath } from "./resolveTaskFolder";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 200;
|
const DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
export type TaskDiagnosticsDeps = {
|
||||||
|
fs: IFileSystem;
|
||||||
|
config: IConfigProvider;
|
||||||
|
};
|
||||||
|
|
||||||
function isTaskDocument(document: vscode.TextDocument): boolean {
|
function isTaskDocument(document: vscode.TextDocument): boolean {
|
||||||
if (document.uri.scheme !== "file" && document.uri.scheme !== "untitled") {
|
if (document.uri.scheme !== "file" && document.uri.scheme !== "untitled") {
|
||||||
return false;
|
return false;
|
||||||
@@ -45,19 +55,46 @@ function toDiagnostic(issue: FrontmatterIssue): vscode.Diagnostic {
|
|||||||
|
|
||||||
export function registerTaskDiagnostics(
|
export function registerTaskDiagnostics(
|
||||||
context: vscode.ExtensionContext,
|
context: vscode.ExtensionContext,
|
||||||
|
deps: TaskDiagnosticsDeps,
|
||||||
): vscode.DiagnosticCollection {
|
): vscode.DiagnosticCollection {
|
||||||
const collection =
|
const collection =
|
||||||
vscode.languages.createDiagnosticCollection("xboctuk-flow");
|
vscode.languages.createDiagnosticCollection("xboctuk-flow");
|
||||||
context.subscriptions.push(collection);
|
context.subscriptions.push(collection);
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** folderPath → knownTags when config exists; undefined key means skip catalog. */
|
||||||
|
const knownTagsByFolder = new Map<string, string[] | undefined>();
|
||||||
|
|
||||||
const lint = (document: vscode.TextDocument): void => {
|
const invalidateFolder = (folderPath: string): void => {
|
||||||
|
knownTagsByFolder.delete(folderPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKnownTags = async (
|
||||||
|
folderPath: string | undefined,
|
||||||
|
): Promise<string[] | undefined> => {
|
||||||
|
if (!folderPath) return undefined;
|
||||||
|
if (knownTagsByFolder.has(folderPath)) {
|
||||||
|
return knownTagsByFolder.get(folderPath);
|
||||||
|
}
|
||||||
|
const loaded = await loadProjectConfig(deps.fs, folderPath);
|
||||||
|
if (loaded.isErr() || !loaded.value.exists) {
|
||||||
|
knownTagsByFolder.set(folderPath, undefined);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
knownTagsByFolder.set(folderPath, loaded.value.config.tags);
|
||||||
|
return loaded.value.config.tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lint = async (document: vscode.TextDocument): Promise<void> => {
|
||||||
if (!isTaskDocument(document)) {
|
if (!isTaskDocument(document)) {
|
||||||
collection.delete(document.uri);
|
collection.delete(document.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const issues = validateFrontmatter(document.getText());
|
|
||||||
|
const locations = resolveTaskLocations(deps.config);
|
||||||
|
const folder = resolveTaskFolderForPath(document.fileName, locations);
|
||||||
|
const knownTags = await getKnownTags(folder);
|
||||||
|
const issues = validateFrontmatter(document.getText(), { knownTags });
|
||||||
collection.set(document.uri, issues.map(toDiagnostic));
|
collection.set(document.uri, issues.map(toDiagnostic));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,17 +106,43 @@ export function registerTaskDiagnostics(
|
|||||||
key,
|
key,
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
timers.delete(key);
|
timers.delete(key);
|
||||||
lint(document);
|
void lint(document);
|
||||||
}, DEBOUNCE_MS),
|
}, DEBOUNCE_MS),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const relintAllTasks = (): void => {
|
||||||
|
for (const document of vscode.workspace.textDocuments) {
|
||||||
|
if (isTaskDocument(document)) {
|
||||||
|
void lint(document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const document of vscode.workspace.textDocuments) {
|
for (const document of vscode.workspace.textDocuments) {
|
||||||
lint(document);
|
void lint(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const location of resolveTaskLocations(deps.config)) {
|
||||||
|
const configPattern = new vscode.RelativePattern(
|
||||||
|
location.folderPath,
|
||||||
|
TASK_FOLDER_CONFIG_FILENAME,
|
||||||
|
);
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(configPattern);
|
||||||
|
const onConfigChange = (): void => {
|
||||||
|
invalidateFolder(location.folderPath);
|
||||||
|
relintAllTasks();
|
||||||
|
};
|
||||||
|
watcher.onDidCreate(onConfigChange);
|
||||||
|
watcher.onDidChange(onConfigChange);
|
||||||
|
watcher.onDidDelete(onConfigChange);
|
||||||
|
context.subscriptions.push(watcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.workspace.onDidOpenTextDocument(lint),
|
vscode.workspace.onDidOpenTextDocument((doc) => {
|
||||||
|
void lint(doc);
|
||||||
|
}),
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
lintDebounced(event.document);
|
lintDebounced(event.document);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Quote a YAML scalar for a single-line frontmatter value. */
|
||||||
|
export function yamlInlineScalar(value: string): string {
|
||||||
|
if (value === "") {
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
// Safe unquoted token (no spaces / special YAML chars).
|
||||||
|
if (/^[A-Za-z0-9_./+-]+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user