Skip to content

Commit 3e293a6

Browse files
committed
feat: implement tag indexing service with comprehensive tests
1 parent b8ee995 commit 3e293a6

File tree

3 files changed

+482
-8
lines changed

3 files changed

+482
-8
lines changed

.agent-os/specs/2025-10-18-note-tagging-system/tasks.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ These are the tasks to be completed for the spec detailed in @.agent-os/specs/20
1414
- [x] 1.4 Implement `isValidTag()` and `formatTag*()` helpers
1515
- [x] 1.5 Verify all unit tests pass
1616

17-
- [ ] 2. Build tag indexing service
18-
- [ ] 2.1 Write tests for TagService class methods
19-
- [ ] 2.2 Create `src/services/tagService.ts` with TagService class
20-
- [ ] 2.3 Implement `buildTagIndex()` to scan notes and extract tags
21-
- [ ] 2.4 Implement `getTagsForNote()` and `getNotesWithTag()` methods
22-
- [ ] 2.5 Implement `getAllTags()` with sorting options
23-
- [ ] 2.6 Add in-memory caching for tag index
24-
- [ ] 2.7 Verify all tests pass
17+
- [x] 2. Build tag indexing service
18+
- [x] 2.1 Write tests for TagService class methods
19+
- [x] 2.2 Create `src/services/tagService.ts` with TagService class
20+
- [x] 2.3 Implement `buildTagIndex()` to scan notes and extract tags
21+
- [x] 2.4 Implement `getTagsForNote()` and `getNotesWithTag()` methods
22+
- [x] 2.5 Implement `getAllTags()` with sorting options
23+
- [x] 2.6 Add in-memory caching for tag index
24+
- [x] 2.7 Verify all tests pass
2525

2626
- [ ] 3. Create tags tree view provider
2727
- [ ] 3.1 Write tests for TagsTreeProvider

src/services/tagService.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import * as path from 'path';
2+
import { readFile, readDirectoryWithTypes, pathExists } from './fileSystemService';
3+
import { extractTagsFromContent } from '../utils/tagHelpers';
4+
import { SUPPORTED_EXTENSIONS } from '../constants';
5+
6+
/**
7+
* Represents a tag with metadata
8+
*/
9+
export interface TagInfo {
10+
name: string;
11+
count: number;
12+
notes: string[];
13+
}
14+
15+
/**
16+
* Type for tag sort order
17+
*/
18+
export type TagSortOrder = 'frequency' | 'alphabetical';
19+
20+
/**
21+
* Service for managing tag indexing and retrieval
22+
*/
23+
export class TagService {
24+
private tagIndex: Map<string, Set<string>> = new Map();
25+
private notesPath: string;
26+
27+
constructor(notesPath: string) {
28+
this.notesPath = notesPath;
29+
}
30+
31+
/**
32+
* Build the tag index by scanning all notes in the notes directory
33+
*/
34+
async buildTagIndex(): Promise<void> {
35+
this.clearCache();
36+
37+
if (!(await pathExists(this.notesPath))) {
38+
return;
39+
}
40+
41+
await this.scanDirectory(this.notesPath);
42+
}
43+
44+
/**
45+
* Recursively scan a directory and extract tags from note files
46+
*/
47+
private async scanDirectory(dirPath: string): Promise<void> {
48+
try {
49+
const entries = await readDirectoryWithTypes(dirPath);
50+
51+
for (const entry of entries) {
52+
const fullPath = path.join(dirPath, entry.name);
53+
54+
if (entry.isDirectory()) {
55+
await this.scanDirectory(fullPath);
56+
} else if (entry.isFile()) {
57+
const ext = path.extname(entry.name);
58+
if (SUPPORTED_EXTENSIONS.includes(ext)) {
59+
await this.extractTagsFromFile(fullPath);
60+
}
61+
}
62+
}
63+
} catch (error) {
64+
// Silently ignore directory read errors (e.g., permissions, non-existent)
65+
console.error(`[NOTED] Error scanning directory: ${dirPath}`, error);
66+
}
67+
}
68+
69+
/**
70+
* Extract tags from a single file and add to index
71+
*/
72+
private async extractTagsFromFile(filePath: string): Promise<void> {
73+
try {
74+
const content = await readFile(filePath);
75+
const tags = extractTagsFromContent(content);
76+
77+
for (const tag of tags) {
78+
if (!this.tagIndex.has(tag)) {
79+
this.tagIndex.set(tag, new Set());
80+
}
81+
this.tagIndex.get(tag)!.add(filePath);
82+
}
83+
} catch (error) {
84+
// Silently ignore file read errors
85+
console.error(`[NOTED] Error reading file for tags: ${filePath}`, error);
86+
}
87+
}
88+
89+
/**
90+
* Get all tags for a specific note file
91+
*/
92+
getTagsForNote(notePath: string): string[] {
93+
const tags: string[] = [];
94+
95+
for (const [tag, notes] of this.tagIndex.entries()) {
96+
if (notes.has(notePath)) {
97+
tags.push(tag);
98+
}
99+
}
100+
101+
return tags;
102+
}
103+
104+
/**
105+
* Get all note file paths that contain a specific tag
106+
*/
107+
getNotesWithTag(tag: string): string[] {
108+
const normalizedTag = tag.toLowerCase();
109+
const notes = this.tagIndex.get(normalizedTag);
110+
return notes ? Array.from(notes) : [];
111+
}
112+
113+
/**
114+
* Get all note file paths that contain ALL specified tags (AND logic)
115+
*/
116+
getNotesWithTags(tags: string[]): string[] {
117+
if (tags.length === 0) {
118+
return [];
119+
}
120+
121+
// Get notes for first tag
122+
const normalizedTags = tags.map(t => t.toLowerCase());
123+
let result = new Set(this.getNotesWithTag(normalizedTags[0]));
124+
125+
// Intersect with notes for remaining tags
126+
for (let i = 1; i < normalizedTags.length; i++) {
127+
const notesWithTag = new Set(this.getNotesWithTag(normalizedTags[i]));
128+
result = new Set([...result].filter(note => notesWithTag.has(note)));
129+
}
130+
131+
return Array.from(result);
132+
}
133+
134+
/**
135+
* Get all tags with metadata, sorted by specified order
136+
*/
137+
getAllTags(sortOrder: TagSortOrder = 'frequency'): TagInfo[] {
138+
const tags: TagInfo[] = [];
139+
140+
for (const [tag, notes] of this.tagIndex.entries()) {
141+
tags.push({
142+
name: tag,
143+
count: notes.size,
144+
notes: Array.from(notes)
145+
});
146+
}
147+
148+
// Sort based on specified order
149+
if (sortOrder === 'frequency') {
150+
tags.sort((a, b) => {
151+
// Sort by count descending, then by name ascending
152+
if (b.count !== a.count) {
153+
return b.count - a.count;
154+
}
155+
return a.name.localeCompare(b.name);
156+
});
157+
} else {
158+
tags.sort((a, b) => a.name.localeCompare(b.name));
159+
}
160+
161+
return tags;
162+
}
163+
164+
/**
165+
* Clear the tag index cache
166+
*/
167+
clearCache(): void {
168+
this.tagIndex.clear();
169+
}
170+
171+
/**
172+
* Get the total number of unique tags
173+
*/
174+
getTagCount(): number {
175+
return this.tagIndex.size;
176+
}
177+
178+
/**
179+
* Check if a tag exists in the index
180+
*/
181+
hasTag(tag: string): boolean {
182+
return this.tagIndex.has(tag.toLowerCase());
183+
}
184+
}

0 commit comments

Comments
 (0)