Skip to content

Commit d7ccc55

Browse files
authored
feat: configurable Bear tags + fix double-title bug (#283)
* feat: configurable Bear tags + fix double-title bug - Fix double title: strip H1 from Bear note body since `title` URL param already carries it - Add custom tags setting (comma-separated, kebab-case) with auto-normalization; empty = auto-generated tags (existing behavior) - Add tag position setting: prepend (after title) or append (default) - Settings UI in Bear tab with inputs matching existing design - Dev mock: add /api/approve + /api/save-notes handlers for Bear testing in dev mode * revert: drop dev-mock-api changes * fix: simplify H1 stripping regex — no wording assumption * test: add Bear integration tests + restore JSDoc comments * refactor: export Bear helpers from integrations, import in tests * test: use dummy title in Bear tests
1 parent 7817c46 commit d7ccc55

5 files changed

Lines changed: 279 additions & 33 deletions

File tree

packages/editor/App.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,11 @@ const App: React.FC = () => {
506506
}
507507

508508
if (bearSettings.enabled) {
509-
body.bear = { plan: markdown };
509+
body.bear = {
510+
plan: markdown,
511+
customTags: bearSettings.customTags,
512+
tagPosition: bearSettings.tagPosition,
513+
};
510514
}
511515

512516
// Include annotations as feedback if any exist (for OpenCode "approve with notes")
@@ -731,7 +735,12 @@ const App: React.FC = () => {
731735
}
732736
}
733737
if (target === 'bear') {
734-
body.bear = { plan: markdown };
738+
const bs = getBearSettings();
739+
body.bear = {
740+
plan: markdown,
741+
customTags: bs.customTags,
742+
tagPosition: bs.tagPosition,
743+
};
735744
}
736745

737746
try {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Bear Integration Tests
3+
*
4+
* Run: bun test packages/server/integrations.test.ts
5+
*/
6+
7+
import { describe, expect, test } from "bun:test";
8+
import {
9+
extractTitle,
10+
extractTags,
11+
stripH1,
12+
buildHashtags,
13+
buildBearContent,
14+
} from "./integrations";
15+
16+
describe("extractTitle", () => {
17+
test("extracts plain H1", () => {
18+
expect(extractTitle("# My Plan\n\nContent")).toBe("My Plan");
19+
});
20+
21+
test("strips Implementation Plan: prefix", () => {
22+
expect(extractTitle("# Implementation Plan: Auth Flow\n\nContent")).toBe("Auth Flow");
23+
});
24+
25+
test("strips Plan: prefix", () => {
26+
expect(extractTitle("# Plan: Database Migration\n\nContent")).toBe("Database Migration");
27+
});
28+
29+
test("falls back to 'Plan' when no H1", () => {
30+
expect(extractTitle("No heading here")).toBe("Plan");
31+
});
32+
33+
test("truncates to 50 chars", () => {
34+
const long = "A".repeat(60);
35+
expect(extractTitle(`# ${long}`).length).toBe(50);
36+
});
37+
38+
test("removes special characters", () => {
39+
expect(extractTitle("# Fix [bug] #123")).toBe("Fix bug 123");
40+
});
41+
});
42+
43+
describe("stripH1", () => {
44+
test("strips first H1 line", () => {
45+
expect(stripH1("# My Plan\n\n## Section\nContent")).toBe("## Section\nContent");
46+
});
47+
48+
test("strips H1 with any wording", () => {
49+
expect(stripH1("# Whatever Title Here\nBody")).toBe("Body");
50+
});
51+
52+
test("only strips first H1, not subsequent ones", () => {
53+
const input = "# First\n\n# Second\nBody";
54+
expect(stripH1(input)).toBe("# Second\nBody");
55+
});
56+
57+
test("handles plan with no H1", () => {
58+
expect(stripH1("Just text\nMore text")).toBe("Just text\nMore text");
59+
});
60+
61+
test("does not strip ## H2 headings", () => {
62+
expect(stripH1("## Not H1\nBody")).toBe("## Not H1\nBody");
63+
});
64+
});
65+
66+
describe("buildHashtags", () => {
67+
test("uses custom tags when provided", () => {
68+
expect(buildHashtags("plan, work", ["plannotator"])).toBe("#plan #work");
69+
});
70+
71+
test("falls back to auto tags when custom is empty", () => {
72+
expect(buildHashtags("", ["plannotator", "myproject"])).toBe("#plannotator #myproject");
73+
});
74+
75+
test("falls back to auto tags when custom is undefined", () => {
76+
expect(buildHashtags(undefined, ["plannotator"])).toBe("#plannotator");
77+
});
78+
79+
test("filters empty tags from trailing comma", () => {
80+
expect(buildHashtags("plan, work,", ["plannotator"])).toBe("#plan #work");
81+
});
82+
83+
test("handles whitespace-only custom tags as empty", () => {
84+
expect(buildHashtags(" ", ["auto"])).toBe("#auto");
85+
});
86+
});
87+
88+
describe("buildBearContent", () => {
89+
test("appends tags by default", () => {
90+
const result = buildBearContent("Body text", "#plan #work", "append");
91+
expect(result).toBe("Body text\n\n#plan #work");
92+
});
93+
94+
test("prepends tags when configured", () => {
95+
const result = buildBearContent("Body text", "#plan #work", "prepend");
96+
expect(result).toBe("#plan #work\n\nBody text");
97+
});
98+
});
99+
100+
describe("full Bear content pipeline", () => {
101+
const plan = "# Add user authentication flow\n\n## Context\nSome content here";
102+
103+
test("no double title — H1 stripped from body", () => {
104+
const body = stripH1(plan);
105+
expect(body).not.toContain("# Add user");
106+
expect(body).toStartWith("## Context");
107+
});
108+
109+
test("custom tags prepended after title removal", () => {
110+
const body = stripH1(plan);
111+
const hashtags = buildHashtags("plan, work", []);
112+
const content = buildBearContent(body, hashtags, "prepend");
113+
expect(content).toStartWith("#plan #work");
114+
expect(content).toContain("## Context");
115+
expect(content).not.toContain("# Add user");
116+
});
117+
118+
test("auto tags appended when no custom tags", () => {
119+
const body = stripH1(plan);
120+
const hashtags = buildHashtags("", ["plannotator", "dev"]);
121+
const content = buildBearContent(body, hashtags, "append");
122+
expect(content).toEndWith("#plannotator #dev");
123+
expect(content).toStartWith("## Context");
124+
});
125+
});
126+
127+
describe("extractTags", () => {
128+
test("always includes plannotator tag", async () => {
129+
const tags = await extractTags("# Simple Plan\n\nContent");
130+
expect(tags).toContain("plannotator");
131+
});
132+
133+
test("extracts words from title", async () => {
134+
const tags = await extractTags("# Authentication Service Refactor\n\nContent");
135+
expect(tags).toContain("authentication");
136+
expect(tags).toContain("service");
137+
expect(tags).toContain("refactor");
138+
});
139+
140+
test("filters stop words from title", async () => {
141+
const tags = await extractTags("# Implementation Plan for the System\n\nContent");
142+
expect(tags).not.toContain("implementation");
143+
expect(tags).not.toContain("plan");
144+
expect(tags).not.toContain("the");
145+
expect(tags).not.toContain("for");
146+
});
147+
148+
test("extracts code fence languages", async () => {
149+
const tags = await extractTags("# Plan\n\n```typescript\ncode\n```\n\n```rust\ncode\n```");
150+
expect(tags).toContain("typescript");
151+
expect(tags).toContain("rust");
152+
});
153+
154+
test("skips generic languages", async () => {
155+
const tags = await extractTags("# Plan\n\n```json\n{}\n```\n\n```yaml\nfoo\n```");
156+
expect(tags).not.toContain("json");
157+
expect(tags).not.toContain("yaml");
158+
});
159+
160+
test("limits to 7 tags", async () => {
161+
const tags = await extractTags("# One Two Three Four\n\n```go\n```\n```python\n```\n```ruby\n```\n```swift\n```");
162+
expect(tags.length).toBeLessThanOrEqual(7);
163+
});
164+
});

packages/server/integrations.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface ObsidianConfig {
1919

2020
export interface BearConfig {
2121
plan: string;
22+
customTags?: string;
23+
tagPosition?: 'prepend' | 'append';
2224
}
2325

2426
export interface IntegrationResult {
@@ -274,25 +276,42 @@ export async function saveToObsidian(config: ObsidianConfig): Promise<Integratio
274276

275277
// --- Bear Integration ---
276278

279+
export function stripH1(plan: string): string {
280+
return plan.replace(/^#\s+.+\n?/m, '').trimStart();
281+
}
282+
283+
export function buildHashtags(customTags: string | undefined, autoTags: string[]): string {
284+
if (customTags?.trim()) {
285+
return customTags.split(',').map(t => `#${t.trim()}`).filter(t => t !== '#').join(' ');
286+
}
287+
return autoTags.map(t => `#${t}`).join(' ');
288+
}
289+
290+
export function buildBearContent(body: string, hashtags: string, tagPosition: 'prepend' | 'append'): string {
291+
return tagPosition === 'prepend'
292+
? `${hashtags}\n\n${body}`
293+
: `${body}\n\n${hashtags}`;
294+
}
295+
277296
/**
278297
* Save plan to Bear using x-callback-url
279298
*/
280299
export async function saveToBear(config: BearConfig): Promise<IntegrationResult> {
281300
try {
282-
const { plan } = config;
301+
const { plan, customTags, tagPosition = 'append' } = config;
283302

284-
// Extract title and tags
285303
const title = extractTitle(plan);
286-
const tags = await extractTags(plan);
287-
const hashtags = tags.map(t => `#${t}`).join(' ');
304+
const body = stripH1(plan);
305+
306+
const tags = customTags?.trim()
307+
? undefined
308+
: await extractTags(plan);
309+
const hashtags = buildHashtags(customTags, tags ?? []);
288310

289-
// Append hashtags to content
290-
const content = `${plan}\n\n${hashtags}`;
311+
const content = buildBearContent(body, hashtags, tagPosition);
291312

292-
// Build Bear URL
293313
const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`;
294314

295-
// Open Bear via URL scheme
296315
await $`open ${url}`.quiet();
297316

298317
return { success: true };

packages/ui/components/Settings.tsx

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
getBearSettings,
1414
saveBearSettings,
15+
normalizeTags,
1516
type BearSettings,
1617
} from '../utils/bear';
1718
import {
@@ -75,7 +76,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
7576
});
7677
const [detectedVaults, setDetectedVaults] = useState<string[]>([]);
7778
const [vaultsLoading, setVaultsLoading] = useState(false);
78-
const [bear, setBear] = useState<BearSettings>({ enabled: false });
79+
const [bear, setBear] = useState<BearSettings>({ enabled: false, customTags: '', tagPosition: 'append' });
7980
const [agent, setAgent] = useState<AgentSwitchSettings>({ switchTo: 'build' });
8081
const [planSave, setPlanSave] = useState<PlanSaveSettings>({ enabled: true, customPath: null });
8182
const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true });
@@ -159,8 +160,8 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
159160
saveObsidianSettings(newSettings);
160161
};
161162

162-
const handleBearChange = (enabled: boolean) => {
163-
const newSettings = { enabled };
163+
const handleBearChange = (updates: Partial<BearSettings>) => {
164+
const newSettings = { ...bear, ...updates };
164165
setBear(newSettings);
165166
saveBearSettings(newSettings);
166167
};
@@ -1163,28 +1164,59 @@ tags: [plan, ...]
11631164

11641165
{/* === BEAR TAB === */}
11651166
{activeTab === 'bear' && (
1166-
<div className="flex items-center justify-between">
1167-
<div>
1168-
<div className="text-sm font-medium">Bear Notes</div>
1169-
<div className="text-xs text-muted-foreground">
1170-
Auto-save approved plans to Bear
1167+
<>
1168+
<div className="flex items-center justify-between">
1169+
<div>
1170+
<div className="text-sm font-medium">Bear Notes</div>
1171+
<div className="text-xs text-muted-foreground">
1172+
Auto-save approved plans to Bear
1173+
</div>
11711174
</div>
1172-
</div>
1173-
<button
1174-
role="switch"
1175-
aria-checked={bear.enabled}
1176-
onClick={() => handleBearChange(!bear.enabled)}
1177-
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1178-
bear.enabled ? 'bg-primary' : 'bg-muted'
1179-
}`}
1180-
>
1181-
<span
1182-
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1183-
bear.enabled ? 'translate-x-6' : 'translate-x-1'
1175+
<button
1176+
role="switch"
1177+
aria-checked={bear.enabled}
1178+
onClick={() => handleBearChange({ enabled: !bear.enabled })}
1179+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1180+
bear.enabled ? 'bg-primary' : 'bg-muted'
11841181
}`}
1185-
/>
1186-
</button>
1187-
</div>
1182+
>
1183+
<span
1184+
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1185+
bear.enabled ? 'translate-x-6' : 'translate-x-1'
1186+
}`}
1187+
/>
1188+
</button>
1189+
</div>
1190+
{bear.enabled && (
1191+
<div className="mt-3 space-y-3">
1192+
<div className="space-y-1.5 pl-0.5">
1193+
<label className="text-xs text-muted-foreground">Custom Tags</label>
1194+
<input
1195+
type="text"
1196+
value={bear.customTags}
1197+
onChange={(e) => handleBearChange({ customTags: e.target.value })}
1198+
onBlur={(e) => handleBearChange({ customTags: normalizeTags(e.target.value) })}
1199+
placeholder="plan, work"
1200+
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1201+
/>
1202+
<div className="text-[10px] text-muted-foreground">
1203+
Comma-separated, kebab-case. Leave empty for auto-generated tags.
1204+
</div>
1205+
</div>
1206+
<div className="space-y-1.5 pl-0.5">
1207+
<label className="text-xs text-muted-foreground">Tag Position</label>
1208+
<select
1209+
value={bear.tagPosition}
1210+
onChange={(e) => handleBearChange({ tagPosition: e.target.value as 'prepend' | 'append' })}
1211+
className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
1212+
>
1213+
<option value="append">Append (end of note)</option>
1214+
<option value="prepend">Prepend (after title)</option>
1215+
</select>
1216+
</div>
1217+
</div>
1218+
)}
1219+
</>
11881220
)}
11891221

11901222
</div>

0 commit comments

Comments
 (0)