diff --git a/README.md b/README.md index 1335183..bca1daa 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,18 @@ parent-project: "[[Parent Project]]" - [w] Waiting for design review from Sarah ``` +## Context Tags + +Add GTD context tags to your actions to filter by where or how you can do them: + +```markdown +- [ ] Call dentist #context/phone +- [ ] Review budget spreadsheet #context/computer +- [ ] Pick up dry cleaning #context/errands +``` + +Context tags appear as filters in sphere and waiting-for views. The prefix is configurable in settings — change `context` to `at`, `ctx`, or whatever suits your workflow. + ## Cover Image Generation Flow can generate cover images for projects using AI. Configure an image-capable model via [OpenRouter](https://openrouter.ai) in Settings → Flow (`openai/gpt-5-image` creates great cover images in our experience). diff --git a/docs/plans/2026-02-19-configurable-context-tag-prefix-design.md b/docs/plans/2026-02-19-configurable-context-tag-prefix-design.md new file mode 100644 index 0000000..2709368 --- /dev/null +++ b/docs/plans/2026-02-19-configurable-context-tag-prefix-design.md @@ -0,0 +1,32 @@ +# Configurable Context Tag Prefix + +## Problem + +Context tags (`#context/home`, `#context/office`) are hardcoded with the prefix `context`. Users can't discover this feature without reading commits, and can't change the prefix to suit their workflow (e.g. `#at/home`, `#ctx/office`). + +## Design + +### Setting + +Add `contextTagPrefix` to `PluginSettings` with default `"context"`. + +### Core + +Change `extractContexts(text: string)` to `extractContexts(text: string, prefix: string)` — build the regex dynamically from the prefix parameter. + +### Call sites + +Pass `settings.contextTagPrefix` at all call sites: + +- `sphere-view.ts` — has `this.settings` +- `sphere-data-loader.ts` — has `this.settings` +- `someday-scanner.ts` — has `this.settings` +- `waiting-for-scanner.ts` — needs `settings` added to constructor + +### Settings UI + +Text field in the settings tab with description explaining what context tags are and how to use them. + +### README + +Short section after "Project Structure" explaining context tags with examples. diff --git a/src/action-line-finder.ts b/src/action-line-finder.ts index 9382040..70016f3 100644 --- a/src/action-line-finder.ts +++ b/src/action-line-finder.ts @@ -26,8 +26,9 @@ export class ActionLineFinder { // Search for line containing the action text for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Match checkbox lines containing the action text - if (isCheckboxLine(line) && line.includes(actionText)) { + // Strip sphere tags before comparing, since the sphere view removes them from action text + const lineWithoutSphere = line.replace(/#sphere\/[^\s]+/gi, "").replace(/\s{2,}/g, " "); + if (isCheckboxLine(line) && lineWithoutSphere.includes(actionText)) { return { found: true, lineNumber: i + 1, diff --git a/src/context-tags.ts b/src/context-tags.ts index 7383905..aff3fe6 100644 --- a/src/context-tags.ts +++ b/src/context-tags.ts @@ -1,21 +1,18 @@ -// ABOUTME: Extracts GTD context tags (#context/X) from action line text. +// ABOUTME: Extracts GTD context tags (e.g. #context/X) from action line text. // ABOUTME: Used by scanners and views for context-based filtering. -const CONTEXT_TAG_PATTERN = /#context\/([^\s]+)/gi; - -export function extractContexts(text: string): string[] { +export function extractContexts(text: string, prefix: string = "context"): string[] { + const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`#${escaped}\\/([^\\s]+)`, "gi"); const contexts: string[] = []; let match; - while ((match = CONTEXT_TAG_PATTERN.exec(text)) !== null) { + while ((match = pattern.exec(text)) !== null) { const context = match[1].toLowerCase(); if (!contexts.includes(context)) { contexts.push(context); } } - // Reset lastIndex since we're using a global regex - CONTEXT_TAG_PATTERN.lastIndex = 0; - return contexts; } diff --git a/src/settings-tab.ts b/src/settings-tab.ts index f23136c..fcb4505 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -266,6 +266,22 @@ export class FlowGTDSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName("Context tag prefix") + .setDesc( + "Tag prefix for GTD contexts on actions (e.g. #context/home, #context/office). " + + "Change this to use a different prefix like 'at' for #at/home or 'ctx' for #ctx/office." + ) + .addText((text) => + text + .setPlaceholder("context") + .setValue(this.plugin.settings.contextTagPrefix) + .onChange(async (value) => { + this.plugin.settings.contextTagPrefix = value.trim() || "context"; + await this.plugin.saveSettings(); + }) + ); + // Focus Settings new Setting(containerEl).setHeading().setName("Focus"); containerEl diff --git a/src/someday-scanner.ts b/src/someday-scanner.ts index 3cf2d6e..2683a3d 100644 --- a/src/someday-scanner.ts +++ b/src/someday-scanner.ts @@ -96,7 +96,7 @@ export class SomedayScanner { lineContent: line, text, sphere: this.extractSphere(line), - contexts: extractContexts(line), + contexts: extractContexts(line, this.settings.contextTagPrefix), }); } } diff --git a/src/sphere-data-loader.ts b/src/sphere-data-loader.ts index 7daa2a1..3f4af5f 100644 --- a/src/sphere-data-loader.ts +++ b/src/sphere-data-loader.ts @@ -177,7 +177,7 @@ export class SphereDataLoader { } const matchesContext = (action: string) => { - const contexts = extractContexts(action); + const contexts = extractContexts(action, this.settings.contextTagPrefix); return contexts.some((c) => selectedContexts.includes(c)); }; @@ -208,14 +208,14 @@ export class SphereDataLoader { for (const summary of data.projects) { for (const action of summary.project.nextActions || []) { - for (const context of extractContexts(action)) { + for (const context of extractContexts(action, this.settings.contextTagPrefix)) { contexts.add(context); } } } for (const action of data.generalNextActions) { - for (const context of extractContexts(action)) { + for (const context of extractContexts(action, this.settings.contextTagPrefix)) { contexts.add(context); } } diff --git a/src/sphere-view.ts b/src/sphere-view.ts index ecda14c..2997704 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -816,7 +816,7 @@ export class SphereView extends ItemView { sphere, isGeneral, addedAt: Date.now(), - contexts: extractContexts(lineContent), + contexts: extractContexts(lineContent, this.settings.contextTagPrefix), }; const focusItems = await loadFocusItems(this.app.vault); diff --git a/src/types/settings.ts b/src/types/settings.ts index afe0e26..867f645 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -20,6 +20,7 @@ export interface PluginSettings { coverImagesFolderPath: string; // Folder path for generated project cover images autoCreateCoverImage: boolean; // Automatically create cover images for new projects displayCoverImages: boolean; // Display cover images on project notes + contextTagPrefix: string; // Prefix for GTD context tags (e.g. #context/home) spheres: string[]; focusAutoClearTime: string; // Empty string for off, or time in HH:MM format (e.g., "03:00") focusArchiveFile: string; // Path to archive file for cleared tasks @@ -50,6 +51,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { coverImagesFolderPath: "Assets/flow-project-cover-images", autoCreateCoverImage: false, displayCoverImages: true, + contextTagPrefix: "context", spheres: ["personal", "work"], focusAutoClearTime: "03:00", focusArchiveFile: "Focus Archive.md", diff --git a/src/waiting-for-scanner.ts b/src/waiting-for-scanner.ts index 64860f8..b631e21 100644 --- a/src/waiting-for-scanner.ts +++ b/src/waiting-for-scanner.ts @@ -4,6 +4,7 @@ import { App, TFile } from "obsidian"; import { getAPI } from "obsidian-dataview"; import { extractContexts } from "./context-tags"; +import { PluginSettings } from "./types"; export interface WaitingForItem { file: string; @@ -17,9 +18,11 @@ export interface WaitingForItem { export class WaitingForScanner { private app: App; + private settings: PluginSettings; - constructor(app: App) { + constructor(app: App, settings: PluginSettings) { this.app = app; + this.settings = settings; } private extractSphere(lineContent: string, filePath: string): string | undefined { @@ -106,7 +109,7 @@ export class WaitingForScanner { lineContent, text: task.text, sphere: this.extractSphere(lineContent, task.path), - contexts: extractContexts(lineContent), + contexts: extractContexts(lineContent, this.settings.contextTagPrefix), }); } @@ -151,7 +154,7 @@ export class WaitingForScanner { lineContent: line, text, sphere: this.extractSphere(line, file.path), - contexts: extractContexts(line), + contexts: extractContexts(line, this.settings.contextTagPrefix), }); } }); diff --git a/src/waiting-for-view.ts b/src/waiting-for-view.ts index 87c7a86..946498c 100644 --- a/src/waiting-for-view.ts +++ b/src/waiting-for-view.ts @@ -28,7 +28,7 @@ export class WaitingForView extends RefreshingView { constructor(leaf: WorkspaceLeaf, settings: PluginSettings, saveSettings: () => Promise) { super(leaf); this.settings = settings; - this.scanner = new WaitingForScanner(this.app); + this.scanner = new WaitingForScanner(this.app, settings); this.validator = new WaitingForValidator(this.app); this.saveSettings = saveSettings; diff --git a/tests/action-line-finder.test.ts b/tests/action-line-finder.test.ts index 801f3ed..8a42b11 100644 --- a/tests/action-line-finder.test.ts +++ b/tests/action-line-finder.test.ts @@ -106,6 +106,21 @@ describe("ActionLineFinder", () => { expect(result2.lineNumber).toBe(3); }); + it("should find action when sphere tag was stripped but other tags remain", async () => { + const mockFile = new TFile(); + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockVault.read.mockResolvedValue( + "- [ ] Restaurants to go to #sphere/personal #ctx/test\n- [ ] Another action\n" + ); + + // The sphere view strips #sphere/X tags, so the action text won't have it + const result = await finder.findActionLine("Next actions.md", "Restaurants to go to #ctx/test"); + + expect(result.found).toBe(true); + expect(result.lineNumber).toBe(1); + expect(result.lineContent).toBe("- [ ] Restaurants to go to #sphere/personal #ctx/test"); + }); + it("should return first match when action appears multiple times", async () => { const mockFile = new TFile(); mockVault.getAbstractFileByPath.mockReturnValue(mockFile); diff --git a/tests/context-tags.test.ts b/tests/context-tags.test.ts index 8409d9b..e3b9dcb 100644 --- a/tests/context-tags.test.ts +++ b/tests/context-tags.test.ts @@ -39,3 +39,26 @@ describe("extractContexts", () => { expect(extractContexts("Task #context/phone #context/phone")).toEqual(["phone"]); }); }); + +describe("extractContexts with custom prefix", () => { + it("extracts tags with a custom prefix", () => { + expect(extractContexts("Call dentist #at/phone", "at")).toEqual(["phone"]); + }); + + it("extracts multiple tags with a custom prefix", () => { + expect(extractContexts("Task #ctx/home #ctx/errand", "ctx")).toEqual(["home", "errand"]); + }); + + it("ignores default context tags when using custom prefix", () => { + expect(extractContexts("Task #context/phone #at/home", "at")).toEqual(["home"]); + }); + + it("is case-insensitive with custom prefix", () => { + expect(extractContexts("Task #At/Phone", "at")).toEqual(["phone"]); + }); + + it("handles prefix containing regex metacharacters", () => { + expect(extractContexts("Task #c.t/home", "c.t")).toEqual(["home"]); + expect(extractContexts("Task #cat/home", "c.t")).toEqual([]); + }); +}); diff --git a/tests/waiting-for-scanner.test.ts b/tests/waiting-for-scanner.test.ts index 0e2b27a..f32e527 100644 --- a/tests/waiting-for-scanner.test.ts +++ b/tests/waiting-for-scanner.test.ts @@ -1,5 +1,9 @@ +// ABOUTME: Tests for WaitingForScanner vault scanning and item extraction. +// ABOUTME: Covers sphere/context extraction, text normalisation, and Dataview integration. + import { WaitingForScanner } from "../src/waiting-for-scanner"; import { App, TFile, Vault, MetadataCache, CachedMetadata } from "obsidian"; +import { DEFAULT_SETTINGS } from "../src/types"; describe("WaitingForScanner", () => { let mockApp: jest.Mocked; @@ -23,7 +27,7 @@ describe("WaitingForScanner", () => { metadataCache: mockMetadataCache, } as unknown as jest.Mocked; - scanner = new WaitingForScanner(mockApp); + scanner = new WaitingForScanner(mockApp, DEFAULT_SETTINGS); }); test("should scan vault and find waiting-for items", async () => { diff --git a/tests/waiting-for-view.test.ts b/tests/waiting-for-view.test.ts index 8fd07c8..89e26f5 100644 --- a/tests/waiting-for-view.test.ts +++ b/tests/waiting-for-view.test.ts @@ -40,7 +40,10 @@ describe("WaitingForView", () => { view = new WaitingForView(mockLeaf as WorkspaceLeaf, mockSettings, mockSaveSettings); (view as any).app = mockApp; - mockScanner = new WaitingForScanner(mockApp as any) as jest.Mocked; + mockScanner = new WaitingForScanner( + mockApp as any, + mockSettings + ) as jest.Mocked; (view as any).scanner = mockScanner; // Also replace the validator with one that uses the mock app