Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
32 changes: 32 additions & 0 deletions docs/plans/2026-02-19-configurable-context-tag-prefix-design.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions src/action-line-finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 5 additions & 8 deletions src/context-tags.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions src/settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/someday-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class SomedayScanner {
lineContent: line,
text,
sphere: this.extractSphere(line),
contexts: extractContexts(line),
contexts: extractContexts(line, this.settings.contextTagPrefix),
});
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/sphere-data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};

Expand Down Expand Up @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/sphere-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions src/waiting-for-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
});
Comment on lines +112 to 113
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether contextTagPrefix is validated or escaped elsewhere
echo "=== Searching for contextTagPrefix usage ==="
rg -n "contextTagPrefix" -S src tests

echo -e "\n=== Searching for extractContexts usage ==="
rg -n "extractContexts" -S src

echo -e "\n=== Searching for existing escape/sanitize functions ==="
rg -n "escapeRegExp|sanitize|validate.*prefix" -S src

Repository: tavva/flow

Length of output: 3451


🏁 Script executed:

cat -n src/context-tags.ts

Repository: tavva/flow

Length of output: 710


Escape contextTagPrefix in extractContexts before building RegExp.

The function in src/context-tags.ts (line 5) builds a RegExp directly from the user-configured prefix without escaping. Since the settings tab only applies .trim() validation (src/settings-tab.ts:280), metacharacters like [, (, +, \ can cause RegExp syntax errors or unintended matching behavior at runtime. This affects all five call sites: src/waiting-for-scanner.ts (lines 112, 157), src/sphere-view.ts (line 819), src/sphere-data-loader.ts (lines 180, 211, 218), and src/someday-scanner.ts (line 99).

Escape the prefix using .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") in extractContexts before building the pattern:

Suggested fix
export function extractContexts(text: string, prefix: string = "context"): string[] {
+  const safePrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-  const pattern = new RegExp(`#${prefix}\\/([^\\s]+)`, "gi");
+  const pattern = new RegExp(`#${safePrefix}\\/([^\\s]+)`, "gi");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/waiting-for-scanner.ts` around lines 112 - 113, extractContexts is
constructing a RegExp directly from the user-configured contextTagPrefix
(settings.contextTagPrefix) which can contain regex metacharacters; update
extractContexts to escape the prefix before building the pattern by running the
prefix through .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") (i.e., let safePrefix =
prefix.replace(...)) and use safePrefix when creating the RegExp so all call
sites (e.g., where extractContexts is invoked from waiting-for-scanner,
sphere-view, sphere-data-loader, someday-scanner) no longer throw or mis-match
on special characters.

}

Expand Down Expand Up @@ -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),
});
}
});
Expand Down
2 changes: 1 addition & 1 deletion src/waiting-for-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class WaitingForView extends RefreshingView {
constructor(leaf: WorkspaceLeaf, settings: PluginSettings, saveSettings: () => Promise<void>) {
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;

Expand Down
15 changes: 15 additions & 0 deletions tests/action-line-finder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions tests/context-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
6 changes: 5 additions & 1 deletion tests/waiting-for-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines 4 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add ABOUTME header for this test file.

Please prepend two ABOUTME lines describing the test file’s purpose.

✅ Proposed fix
+// ABOUTME: Tests WaitingForScanner scanning for waiting-for items.
+// ABOUTME: Covers sphere/context extraction and text normalization.
 import { WaitingForScanner } from "../src/waiting-for-scanner";
 import { App, TFile, Vault, MetadataCache, CachedMetadata } from "obsidian";
 import { DEFAULT_SETTINGS } from "../src/types";

As per coding guidelines, Start all source files with two ABOUTME comment lines explaining file purpose.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { WaitingForScanner } from "../src/waiting-for-scanner";
import { App, TFile, Vault, MetadataCache, CachedMetadata } from "obsidian";
import { DEFAULT_SETTINGS } from "../src/types";
// ABOUTME: Tests WaitingForScanner scanning for waiting-for items.
// ABOUTME: Covers sphere/context extraction and text normalization.
import { WaitingForScanner } from "../src/waiting-for-scanner";
import { App, TFile, Vault, MetadataCache, CachedMetadata } from "obsidian";
import { DEFAULT_SETTINGS } from "../src/types";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/waiting-for-scanner.test.ts` around lines 1 - 3, This test file is
missing the required two ABOUTME header comment lines; open the file containing
the WaitingForScanner test (the module that imports WaitingForScanner, App,
TFile, Vault, MetadataCache, CachedMetadata and DEFAULT_SETTINGS) and prepend
two comment lines starting with ABOUTME that succinctly describe the file’s
purpose (e.g., what the test covers and its scope) so the test file begins with
the two ABOUTME header comments as per coding guidelines.


describe("WaitingForScanner", () => {
let mockApp: jest.Mocked<App>;
Expand All @@ -23,7 +27,7 @@ describe("WaitingForScanner", () => {
metadataCache: mockMetadataCache,
} as unknown as jest.Mocked<App>;

scanner = new WaitingForScanner(mockApp);
scanner = new WaitingForScanner(mockApp, DEFAULT_SETTINGS);
});

test("should scan vault and find waiting-for items", async () => {
Expand Down
5 changes: 4 additions & 1 deletion tests/waiting-for-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WaitingForScanner>;
mockScanner = new WaitingForScanner(
mockApp as any,
mockSettings
) as jest.Mocked<WaitingForScanner>;
(view as any).scanner = mockScanner;

// Also replace the validator with one that uses the mock app
Expand Down
Loading