docs: remove deprecated integration checklists and documentation files

This commit is contained in:
2025-10-05 10:31:50 -04:00
parent 8c5394ed64
commit c0ebfcf5b9
30 changed files with 3976 additions and 259 deletions
+76 -5
View File
@@ -61,6 +61,68 @@ import { SearchBarComponent } from './components/search-bar/search-bar.component
## Services
### SearchOrchestratorService ⭐ NEW (Recommended)
Unified search pipeline with complete operator support and highlighting.
```typescript
constructor(private orchestrator: SearchOrchestratorService) {}
// Execute search with all features
const results = this.orchestrator.execute('tag:#work', {
caseSensitive: false,
contextLines: 5, // Lines of context around matches
maxResults: 100 // Limit results
});
// Results include:
// - noteId: string
// - matches: SearchMatch[] (with ranges for highlighting)
// - score: number
// - allRanges: MatchRange[]
```
### SearchHighlighterService ⭐ NEW
Robust highlighting with range support.
```typescript
constructor(private highlighter: SearchHighlighterService) {}
// Highlight using ranges
const html = this.highlighter.highlightWithRanges(text, ranges);
// Highlight using search terms
const html = this.highlighter.highlightMatches(text, ['term1', 'term2'], false);
// Highlight using regex
const html = this.highlighter.highlightRegex(text, '\\d+', false);
// Extract snippet with context
const snippet = this.highlighter.extractSnippet(lines, matchLine, contextLines);
```
### SearchPreferencesService ⭐ NEW
Persistent search preferences per context.
```typescript
constructor(private preferences: SearchPreferencesService) {}
// Get preferences for a context
const prefs = this.preferences.getPreferences('vault');
// Update preferences
this.preferences.updatePreferences('vault', {
collapseResults: true,
showMoreContext: true,
contextLines: 5
});
// Toggle a preference
this.preferences.togglePreference('vault', 'collapseResults');
```
### SearchIndexService
Indexes vault content for fast searching.
@@ -75,14 +137,14 @@ this.searchIndex.rebuildIndex(notes);
const suggestions = this.searchIndex.getSuggestions('tag', '#');
```
### SearchEvaluatorService
### SearchEvaluatorService (Legacy)
Executes search queries.
**⚠️ Deprecated**: Use `SearchOrchestratorService` for new code.
```typescript
constructor(private evaluator: SearchEvaluatorService) {}
// Search
// Search (delegates to orchestrator)
const results = this.evaluator.search('tag:#work', {
caseSensitive: false
});
@@ -138,17 +200,20 @@ Main search input with Aa and .* buttons.
### SearchResultsComponent
Displays search results with grouping.
Displays search results with grouping and highlighting.
**Inputs:**
- `results: SearchResult[]` - Search results
- `collapseAll: boolean` ⭐ NEW - Collapse all result groups
- `showMoreContext: boolean` ⭐ NEW - Show extended context
- `contextLines: number` ⭐ NEW - Number of context lines
**Outputs:**
- `noteOpen: { noteId: string; line?: number }` - Note open event
### SearchPanelComponent
Complete search UI (bar + results).
Complete search UI (bar + results + options).
**Inputs:**
- `placeholder: string` - Placeholder text
@@ -157,6 +222,12 @@ Complete search UI (bar + results).
**Outputs:**
- `noteOpen: { noteId: string; line?: number }` - Note open event
**Features:**
- ⭐ Collapse results toggle (persistent)
- ⭐ Show more context toggle (persistent)
- ⭐ Explain search terms toggle (persistent)
- Automatic preference loading/saving
## Examples
### Complex Query
+24 -160
View File
@@ -1,10 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { parseSearchQuery, queryToPredicate } from './search-parser';
import { SearchOrchestratorService } from './search-orchestrator.service';
import { SearchOptions } from './search-parser.types';
import { SearchIndexService } from './search-index.service';
/**
* Result of a search query
* @deprecated Use SearchOrchestratorService directly for new code
*/
export interface SearchResult {
noteId: string;
@@ -14,6 +14,7 @@ export interface SearchResult {
/**
* Individual match within a note
* @deprecated Use SearchOrchestratorService directly for new code
*/
export interface SearchMatch {
type: 'content' | 'heading' | 'task' | 'property';
@@ -27,174 +28,37 @@ export interface SearchMatch {
/**
* Search evaluator service
* Executes search queries against the indexed vault
*
* @deprecated This service is maintained for backward compatibility.
* Use SearchOrchestratorService for new code which provides better
* filtering, highlighting, and performance.
*/
@Injectable({
providedIn: 'root'
})
export class SearchEvaluatorService {
private searchIndex = inject(SearchIndexService);
private orchestrator = inject(SearchOrchestratorService);
/**
* Execute a search query and return matching notes
* @deprecated Use SearchOrchestratorService.execute() instead
*/
search(query: string, options?: SearchOptions): SearchResult[] {
if (!query || !query.trim()) {
return [];
}
// Parse the query into an AST
const parsed = parseSearchQuery(query, options);
if (parsed.isEmpty) {
return [];
}
// Convert to predicate function
const predicate = queryToPredicate(parsed, options);
// Evaluate against all indexed contexts
const results: SearchResult[] = [];
const allContexts = this.searchIndex.getAllContexts();
for (const context of allContexts) {
if (predicate(context)) {
// Find matches within the content
const matches = this.findMatches(context, query);
const score = this.calculateScore(context, query, matches);
results.push({
noteId: context.filePath, // Using filePath as noteId
matches,
score
});
}
}
// Sort by score (descending)
results.sort((a, b) => b.score - a.score);
return results;
}
/**
* Find specific matches within a context
*/
private findMatches(context: any, query: string): SearchMatch[] {
const matches: SearchMatch[] = [];
const queryLower = query.toLowerCase();
// Simple keyword extraction (can be enhanced)
const keywords = this.extractKeywords(query);
// Search in content
keywords.forEach(keyword => {
const keywordLower = keyword.toLowerCase();
let index = 0;
const contentLower = context.content.toLowerCase();
while ((index = contentLower.indexOf(keywordLower, index)) !== -1) {
const start = Math.max(0, index - 50);
const end = Math.min(context.content.length, index + keyword.length + 50);
const contextText = context.content.substring(start, end);
matches.push({
type: 'content',
text: keyword,
context: (start > 0 ? '...' : '') + contextText + (end < context.content.length ? '...' : ''),
startOffset: index,
endOffset: index + keyword.length
});
index += keyword.length;
}
});
// Search in headings
context.sections?.forEach((section: any) => {
keywords.forEach(keyword => {
if (section.heading.toLowerCase().includes(keyword.toLowerCase())) {
matches.push({
type: 'heading',
text: keyword,
context: section.heading
});
}
});
});
// Search in tasks
context.tasks?.forEach((task: any, index: number) => {
keywords.forEach(keyword => {
if (task.text.toLowerCase().includes(keyword.toLowerCase())) {
matches.push({
type: 'task',
text: keyword,
context: task.text,
line: task.line
});
}
});
});
// Limit matches to avoid overwhelming results
return matches.slice(0, 10);
}
/**
* Extract keywords from query for highlighting
*/
private extractKeywords(query: string): string[] {
const keywords: string[] = [];
// Delegate to the new orchestrator
const results = this.orchestrator.execute(query, options);
// Remove operators and extract actual search terms
const cleaned = query
.replace(/\b(AND|OR|NOT)\b/gi, '')
.replace(/[()]/g, ' ')
.replace(/-\w+/g, '') // Remove negated terms
.replace(/\w+:/g, ''); // Remove prefixes
// Extract quoted phrases
const quotedMatches = cleaned.match(/"([^"]+)"/g);
if (quotedMatches) {
quotedMatches.forEach(match => {
keywords.push(match.replace(/"/g, ''));
});
}
// Extract individual words (longer than 2 chars)
const words = cleaned
.replace(/"[^"]+"/g, '')
.split(/\s+/)
.filter(w => w.length > 2);
keywords.push(...words);
return Array.from(new Set(keywords)); // Deduplicate
}
/**
* Calculate relevance score for a match
*/
private calculateScore(context: any, query: string, matches: SearchMatch[]): number {
let score = 0;
// Base score from number of matches
score += matches.length * 10;
// Bonus for matches in headings
const headingMatches = matches.filter(m => m.type === 'heading');
score += headingMatches.length * 20;
// Bonus for matches in file name
const queryLower = query.toLowerCase();
if (context.fileName.toLowerCase().includes(queryLower)) {
score += 50;
}
// Bonus for exact phrase matches
if (context.content.toLowerCase().includes(queryLower)) {
score += 30;
}
return score;
// Convert to legacy format (without ranges)
return results.map(result => ({
noteId: result.noteId,
matches: result.matches.map(match => ({
type: match.type,
text: match.text,
context: match.context,
line: match.line,
startOffset: match.ranges[0]?.start,
endOffset: match.ranges[0]?.end
})),
score: result.score
}));
}
}
@@ -0,0 +1,229 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { SearchHighlighterService } from './search-highlighter.service';
import { MatchRange } from './search-orchestrator.service';
describe('SearchHighlighterService', () => {
let service: SearchHighlighterService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
SearchHighlighterService
]
});
service = TestBed.inject(SearchHighlighterService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('highlightWithRanges', () => {
it('should highlight text using ranges', () => {
const text = 'This is a test string';
const ranges: MatchRange[] = [
{ start: 10, end: 14, line: 1, context: 'content' }
];
const result = service.highlightWithRanges(text, ranges);
expect(result).toContain('<mark');
expect(result).toContain('test');
expect(result).toContain('</mark>');
});
it('should handle multiple ranges', () => {
const text = 'test and test again';
const ranges: MatchRange[] = [
{ start: 0, end: 4, line: 1, context: 'content' },
{ start: 9, end: 13, line: 1, context: 'content' }
];
const result = service.highlightWithRanges(text, ranges);
const markCount = (result.match(/<mark/g) || []).length;
expect(markCount).toBe(2);
});
it('should escape HTML in text', () => {
const text = '<script>alert("xss")</script>';
const ranges: MatchRange[] = [];
const result = service.highlightWithRanges(text, ranges);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('should return escaped text when no ranges provided', () => {
const text = 'Simple text';
const result = service.highlightWithRanges(text, []);
expect(result).toBe('Simple text');
});
});
describe('highlightMatches', () => {
it('should highlight simple text matches', () => {
const text = 'This is a test';
const searchTerms = ['test'];
const result = service.highlightMatches(text, searchTerms, false);
expect(result).toContain('<mark');
expect(result).toContain('test');
});
it('should be case insensitive by default', () => {
const text = 'This is a TEST';
const searchTerms = ['test'];
const result = service.highlightMatches(text, searchTerms, false);
expect(result).toContain('<mark');
expect(result).toContain('TEST');
});
it('should respect case sensitivity', () => {
const text = 'This is a TEST';
const searchTerms = ['test'];
const result = service.highlightMatches(text, searchTerms, true);
expect(result).not.toContain('<mark');
});
it('should highlight multiple terms', () => {
const text = 'foo and bar';
const searchTerms = ['foo', 'bar'];
const result = service.highlightMatches(text, searchTerms, false);
const markCount = (result.match(/<mark/g) || []).length;
expect(markCount).toBe(2);
});
it('should handle empty search terms', () => {
const text = 'Some text';
const result = service.highlightMatches(text, [], false);
expect(result).toBe('Some text');
});
});
describe('highlightRegex', () => {
it('should highlight regex matches', () => {
const text = 'test123 and test456';
const pattern = 'test\\d+';
const result = service.highlightRegex(text, pattern, false);
const markCount = (result.match(/<mark/g) || []).length;
expect(markCount).toBe(2);
});
it('should handle invalid regex gracefully', () => {
const text = 'Some text';
const pattern = '[invalid(';
const result = service.highlightRegex(text, pattern, false);
expect(result).toBe('Some text');
});
it('should respect case sensitivity in regex', () => {
const text = 'Test and TEST';
const pattern = 'test';
const resultInsensitive = service.highlightRegex(text, pattern, false);
expect(resultInsensitive).toContain('<mark');
const resultSensitive = service.highlightRegex(text, pattern, true);
expect(resultSensitive).not.toContain('<mark');
});
});
describe('extractSnippet', () => {
it('should extract snippet with context', () => {
const lines = [
'Line 1',
'Line 2',
'Line 3 with match',
'Line 4',
'Line 5'
];
const result = service.extractSnippet(lines, 3, 1);
expect(result.snippet).toContain('Line 2');
expect(result.snippet).toContain('Line 3 with match');
expect(result.snippet).toContain('Line 4');
expect(result.startLine).toBe(2);
expect(result.endLine).toBe(4);
});
it('should handle edge cases at start of file', () => {
const lines = ['Line 1', 'Line 2', 'Line 3'];
const result = service.extractSnippet(lines, 1, 2);
expect(result.startLine).toBe(1);
});
it('should handle edge cases at end of file', () => {
const lines = ['Line 1', 'Line 2', 'Line 3'];
const result = service.extractSnippet(lines, 3, 2);
expect(result.endLine).toBe(3);
});
it('should truncate long snippets', () => {
const lines = ['A'.repeat(300)];
const result = service.extractSnippet(lines, 1, 0, 200);
expect(result.snippet.length).toBeLessThanOrEqual(203); // 200 + '...'
});
});
describe('extractSnippetWithMoreContext', () => {
it('should extract snippet with character context', () => {
const content = 'A'.repeat(50) + 'MATCH' + 'B'.repeat(50);
const matchOffset = 50;
const result = service.extractSnippetWithMoreContext(content, matchOffset, 10);
expect(result).toContain('MATCH');
expect(result.length).toBeLessThan(content.length);
});
it('should add ellipsis when truncated', () => {
const content = 'A'.repeat(200);
const result = service.extractSnippetWithMoreContext(content, 100, 20);
expect(result).toContain('...');
});
});
describe('countMatches', () => {
it('should count matches correctly', () => {
const text = 'test and test and test';
const count = service.countMatches(text, 'test', false);
expect(count).toBe(3);
});
it('should be case insensitive by default', () => {
const text = 'Test and TEST and test';
const count = service.countMatches(text, 'test', false);
expect(count).toBe(3);
});
it('should respect case sensitivity', () => {
const text = 'Test and TEST and test';
const count = service.countMatches(text, 'test', true);
expect(count).toBe(1);
});
it('should return 0 for no matches', () => {
const count = service.countMatches('foo bar', 'baz', false);
expect(count).toBe(0);
});
});
describe('stripHtml', () => {
it('should remove HTML tags', () => {
const html = '<mark>highlighted</mark> text';
const result = service.stripHtml(html);
expect(result).toBe('highlighted text');
});
it('should handle nested tags', () => {
const html = '<div><span>nested</span></div>';
const result = service.stripHtml(html);
expect(result).toBe('nested');
});
});
});
@@ -0,0 +1,211 @@
import { Injectable } from '@angular/core';
import { MatchRange } from './search-orchestrator.service';
/**
* Search highlighter service
* Provides robust highlighting with support for case sensitivity, regex, and wildcards
*/
@Injectable({
providedIn: 'root'
})
export class SearchHighlighterService {
/**
* Highlight matches in text using ranges
*/
highlightWithRanges(text: string, ranges: MatchRange[]): string {
if (!text || !ranges || ranges.length === 0) {
return this.escapeHtml(text);
}
// Sort ranges by start position
const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
let result = '';
let lastIndex = 0;
for (const range of sortedRanges) {
// Add text before the match
if (range.start > lastIndex) {
result += this.escapeHtml(text.substring(lastIndex, range.start));
}
// Add highlighted match
const matchText = text.substring(range.start, range.end);
result += `<mark class="bg-yellow-200 dark:bg-yellow-600 text-gray-900 dark:text-gray-900 px-0.5 rounded font-medium">${this.escapeHtml(matchText)}</mark>`;
lastIndex = range.end;
}
// Add remaining text
if (lastIndex < text.length) {
result += this.escapeHtml(text.substring(lastIndex));
}
return result;
}
/**
* Highlight matches in text using search terms
*/
highlightMatches(
text: string,
searchTerms: string[],
caseSensitive: boolean = false
): string {
if (!text || !searchTerms || searchTerms.length === 0) {
return this.escapeHtml(text);
}
const ranges: MatchRange[] = [];
for (const term of searchTerms) {
if (!term) continue;
const searchText = caseSensitive ? text : text.toLowerCase();
const searchTerm = caseSensitive ? term : term.toLowerCase();
let index = 0;
while ((index = searchText.indexOf(searchTerm, index)) !== -1) {
ranges.push({
start: index,
end: index + term.length,
line: 0,
context: 'content'
});
index += term.length;
}
}
return this.highlightWithRanges(text, ranges);
}
/**
* Highlight matches using regex pattern
*/
highlightRegex(
text: string,
pattern: string,
caseSensitive: boolean = false
): string {
if (!text || !pattern) {
return this.escapeHtml(text);
}
try {
const flags = caseSensitive ? 'g' : 'gi';
const regex = new RegExp(pattern, flags);
const ranges: MatchRange[] = [];
let match;
while ((match = regex.exec(text)) !== null) {
ranges.push({
start: match.index,
end: match.index + match[0].length,
line: 0,
context: 'content'
});
}
return this.highlightWithRanges(text, ranges);
} catch (e) {
// Invalid regex, return escaped text
return this.escapeHtml(text);
}
}
/**
* Extract snippet with context around a match
*/
extractSnippet(
lines: string[],
matchLine: number,
contextLines: number = 2,
maxLength: number = 200
): { snippet: string; startLine: number; endLine: number } {
const startLine = Math.max(0, matchLine - 1 - contextLines);
const endLine = Math.min(lines.length - 1, matchLine - 1 + contextLines);
const snippetLines = lines.slice(startLine, endLine + 1);
let snippet = snippetLines.join('\n');
// Truncate if too long
if (snippet.length > maxLength) {
snippet = snippet.substring(0, maxLength) + '...';
}
return {
snippet,
startLine: startLine + 1,
endLine: endLine + 1
};
}
/**
* Extract snippet from content with more context
*/
extractSnippetWithMoreContext(
content: string,
matchOffset: number,
contextChars: number = 100
): string {
const start = Math.max(0, matchOffset - contextChars);
const end = Math.min(content.length, matchOffset + contextChars);
let snippet = content.substring(start, end);
// Add ellipsis if truncated
if (start > 0) {
snippet = '...' + snippet;
}
if (end < content.length) {
snippet = snippet + '...';
}
return snippet;
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Remove HTML tags from text
*/
stripHtml(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
/**
* Count matches in text
*/
countMatches(
text: string,
searchTerm: string,
caseSensitive: boolean = false
): number {
if (!text || !searchTerm) return 0;
const searchText = caseSensitive ? text : text.toLowerCase();
const term = caseSensitive ? searchTerm : searchTerm.toLowerCase();
let count = 0;
let index = 0;
while ((index = searchText.indexOf(term, index)) !== -1) {
count++;
index += term.length;
}
return count;
}
}
+117 -1
View File
@@ -218,12 +218,16 @@ export class SearchIndexService {
// Extract tasks
const tasks = this.extractTasks(rawContent);
// Merge and normalize tags from note and frontmatter (with YAML fallback)
const mergedTags = this.normalizeTags(note, rawContent, content);
return {
noteId: note.id,
filePath,
fileName,
fileNameWithExt,
content,
tags: note.tags || [],
tags: mergedTags,
properties: note.frontmatter || {},
lines,
blocks,
@@ -232,6 +236,118 @@ export class SearchIndexService {
};
}
/**
* Normalize tags from multiple sources: note.tags and frontmatter.tags
*/
private normalizeTags(note: Note, rawContent?: string, content?: string): string[] {
const fromNote = Array.isArray((note as any).tags) ? (note as any).tags : [];
const fm = (note as any).frontmatter || {};
let fromFrontmatter: string[] = [];
const fmKeys = Object.keys(fm);
const getFm = (key: string) => fmKeys.find(k => k.toLowerCase() === key)?.toString();
const tagsKey = getFm('tags') || getFm('tag') || getFm('keywords');
if (tagsKey) {
const v = (fm as any)[tagsKey];
if (Array.isArray(v)) {
fromFrontmatter = v as string[];
} else if (typeof v === 'string') {
fromFrontmatter = v.split(/[\s,]+/).filter(Boolean);
}
}
let combined = [...fromNote, ...fromFrontmatter];
// Fallback: parse YAML frontmatter in rawContent if no tags collected yet
if (combined.length === 0 && rawContent) {
const parsed = this.extractTagsFromYaml(rawContent);
if (parsed.length > 0) {
combined = parsed;
}
}
// Inline tags from content like #home #area/dev
const inline: string[] = [];
if (content) {
const re = /(^|[\s(])#([A-Za-z0-9_\/-]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
inline.push(m[2]);
}
}
const all = [...combined, ...inline]
.map(t => String(t).trim())
.filter(t => t.length > 0)
// Prefer rendering with leading '#', evaluator strips it anyway
.map(t => (t.startsWith('#') ? t : `#${t}`));
// Deduplicate
return Array.from(new Set(all));
}
/**
* Extract tags from YAML frontmatter in raw markdown content.
* Supports:
* tags:
* - tag1
* - tag2
* and tags: [tag1, tag2] or tags: tag1, tag2
*/
private extractTagsFromYaml(raw: string): string[] {
const lines = raw.split('\n');
let i = 0;
// find starting fence '---' or '___'
const isFence = (s: string) => {
const t = s.trim();
return t === '---' || t === '___';
};
while (i < lines.length && !isFence(lines[i])) i++;
if (i >= lines.length) return [];
i++;
const yaml: string[] = [];
while (i < lines.length && !isFence(lines[i])) {
yaml.push(lines[i]);
i++;
}
if (yaml.length === 0) return [];
// Find tags key
const joined = yaml.join('\n');
// Inline array form
const inlineMatch = joined.match(/\btags\s*:\s*\[(.*?)\]/i);
if (inlineMatch) {
return inlineMatch[1].split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
}
// Inline comma-separated
const inlineCsv = joined.match(/\btags\s*:\s*([^\n\[]+)/i);
if (inlineCsv) {
return inlineCsv[1].split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
}
// Multiline list
const out: string[] = [];
let inTags = false;
for (const line of yaml) {
const trimmed = line.trim();
if (!inTags) {
if (/^tags\s*:/i.test(trimmed)) {
inTags = true;
}
} else {
if (trimmed.startsWith('-')) {
const val = trimmed.replace(/^-[\s]*/, '').trim();
if (val) out.push(val);
} else if (trimmed === '' || /^[A-Za-z0-9_-]+\s*:/i.test(trimmed)) {
// next key or blank line ends the list
break;
}
}
}
return out;
}
/**
* Extract blocks (paragraphs) from content
*/
@@ -0,0 +1,252 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { SearchOrchestratorService } from './search-orchestrator.service';
import { SearchIndexService } from './search-index.service';
import { SearchContext } from './search-parser.types';
describe('SearchOrchestratorService', () => {
let service: SearchOrchestratorService;
let contexts: SearchContext[];
const mockContext: SearchContext = {
noteId: 'test/note.md',
filePath: 'test/note.md',
fileName: 'note',
fileNameWithExt: 'note.md',
content: 'This is a test note with some content.\nIt has multiple lines.\nAnd mentions test multiple times.',
tags: ['#test', '#example'],
properties: { title: 'Test Note', status: 'draft' },
lines: [
'This is a test note with some content.',
'It has multiple lines.',
'And mentions test multiple times.'
],
blocks: [
'This is a test note with some content.',
'It has multiple lines.\nAnd mentions test multiple times.'
],
sections: [
{ heading: 'Introduction', content: 'This is a test note', level: 1 }
],
tasks: [
{ text: 'Review test cases', completed: false, line: 5 },
{ text: 'Complete testing', completed: true, line: 6 }
]
};
beforeEach(() => {
contexts = [mockContext];
const indexServiceStub = {
getAllContexts: () => contexts
} as SearchIndexService;
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
SearchOrchestratorService,
{ provide: SearchIndexService, useValue: indexServiceStub }
]
});
service = TestBed.inject(SearchOrchestratorService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('Basic search', () => {
it('should find results for simple text search', () => {
const results = service.execute('test');
expect(results.length).toBe(1);
expect(results[0].noteId).toBe('test/note.md');
expect(results[0].matches.length).toBeGreaterThan(0);
});
it('should return empty array for empty query', () => {
const results = service.execute('');
expect(results).toEqual([]);
});
it('should return empty array for non-matching query', () => {
const results = service.execute('nonexistent');
expect(results).toEqual([]);
});
});
describe('Field operators', () => {
it('should filter by file name', () => {
contexts = [mockContext];
const results = service.execute('file:note.md');
expect(results.length).toBe(1);
expect(results[0].noteId).toBe('test/note.md');
});
it('should filter by path', () => {
contexts = [{
...mockContext,
noteId: 'vault/folder/note.md',
filePath: 'vault/folder/note.md'
}];
const results = service.execute('path:vault');
expect(results.length).toBe(1);
expect(results[0].noteId).toBe('vault/folder/note.md');
});
it('should filter by content', () => {
contexts = [mockContext];
const results = service.execute('content:multiple');
expect(results.length).toBe(1);
expect(results[0].matches.some(m => m.text === 'multiple')).toBeTruthy();
});
it('should filter by tag', () => {
contexts = [mockContext];
const results = service.execute('tag:#test');
expect(results.length).toBe(1);
});
it('should filter by property', () => {
contexts = [{
...mockContext,
properties: { status: 'draft', title: 'Test Note' }
}];
const results = service.execute('[status]:draft');
expect(results.length).toBe(1);
});
it('should filter by property existence', () => {
contexts = [{
...mockContext,
properties: { title: 'Custom Title' }
}];
const results = service.execute('[title]');
expect(results.length).toBe(1);
});
});
describe('Task operators', () => {
it('should find all tasks', () => {
const results = service.execute('task:test');
expect(results.length).toBe(1);
expect(results[0].matches.some(m => m.type === 'task')).toBeTruthy();
});
it('should find incomplete tasks', () => {
const results = service.execute('task-todo:Review');
expect(results.length).toBe(1);
});
it('should find completed tasks', () => {
const results = service.execute('task-done:Complete');
expect(results.length).toBe(1);
});
});
describe('Boolean operators', () => {
it('should handle AND operator', () => {
const results = service.execute('test AND multiple');
expect(results.length).toBe(1);
});
it('should handle OR operator', () => {
const results = service.execute('test OR nonexistent');
expect(results.length).toBe(1);
});
it('should handle NOT operator', () => {
const results = service.execute('test -nonexistent');
expect(results.length).toBe(1);
});
});
describe('Case sensitivity', () => {
it('should be case insensitive by default', () => {
const results = service.execute('TEST');
expect(results.length).toBe(1);
});
it('should respect case sensitive option', () => {
const results = service.execute('TEST', { caseSensitive: true });
expect(results.length).toBe(0);
});
it('should handle match-case operator', () => {
const results = service.execute('match-case:test');
expect(results.length).toBe(1);
});
});
describe('Context lines', () => {
it('should include default context lines', () => {
const results = service.execute('test', { contextLines: 2 });
expect(results.length).toBe(1);
expect(results[0].matches.length).toBeGreaterThan(0);
});
it('should include more context when requested', () => {
const results = service.execute('test', { contextLines: 5 });
expect(results.length).toBe(1);
// Context should be larger with more lines
});
});
describe('Match ranges', () => {
it('should include match ranges in results', () => {
const results = service.execute('test');
expect(results.length).toBe(1);
expect(results[0].allRanges).toBeDefined();
expect(results[0].allRanges.length).toBeGreaterThan(0);
});
it('should have correct range positions', () => {
const results = service.execute('test');
const firstRange = results[0].allRanges[0];
expect(firstRange.start).toBeGreaterThanOrEqual(0);
expect(firstRange.end).toBeGreaterThan(firstRange.start);
expect(firstRange.line).toBeGreaterThan(0);
});
});
describe('Scoring', () => {
it('should calculate relevance scores', () => {
const results = service.execute('test');
expect(results[0].score).toBeGreaterThan(0);
});
it('should sort by score descending', () => {
// Add another context with lower relevance
const lowRelevanceContext: SearchContext = {
...mockContext,
noteId: 'other/file.md',
filePath: 'other/file.md',
fileName: 'file',
fileNameWithExt: 'file.md',
content: 'test'
};
contexts = [mockContext, lowRelevanceContext];
const results = service.execute('test');
if (results.length > 1) {
expect(results[0].score).toBeGreaterThanOrEqual(results[1].score);
}
});
});
describe('Max results', () => {
it('should limit results when maxResults is set', () => {
const duplicateContext = {
...mockContext,
noteId: 'duplicate/note.md',
filePath: 'duplicate/note.md',
fileName: 'note-copy',
fileNameWithExt: 'note-copy.md'
} as SearchContext;
contexts = [mockContext, duplicateContext];
const results = service.execute('test', { maxResults: 1 });
expect(results.length).toBeLessThanOrEqual(1);
});
});
});
@@ -0,0 +1,459 @@
import { Injectable, inject } from '@angular/core';
import { parseSearchQuery, queryToPredicate } from './search-parser';
import { SearchOptions, SearchContext } from './search-parser.types';
import { SearchIndexService } from './search-index.service';
import { ClientLoggingService } from '../../services/client-logging.service';
/**
* Match range for highlighting
*/
export interface MatchRange {
start: number;
end: number;
line: number;
context: 'content' | 'file' | 'path' | 'tag' | 'property' | 'task' | 'heading';
}
/**
* Individual match within a note
*/
export interface SearchMatch {
type: 'content' | 'heading' | 'task' | 'property';
text: string;
context: string;
line?: number;
ranges: MatchRange[];
}
/**
* Result of a search query
*/
export interface SearchResult {
noteId: string;
matches: SearchMatch[];
score: number;
allRanges: MatchRange[];
}
/**
* Execution options for search
*/
export interface SearchExecutionOptions extends SearchOptions {
/** Number of context lines to show around matches (default: 2) */
contextLines?: number;
/** Maximum number of results to return (default: unlimited) */
maxResults?: number;
}
/**
* Search orchestrator service
* Unifies the search pipeline: parsing → execution plan → evaluation → highlighting
*/
@Injectable({
providedIn: 'root'
})
export class SearchOrchestratorService {
private searchIndex = inject(SearchIndexService);
private logger = inject(ClientLoggingService);
/**
* Execute a search query and return matching notes with highlights
*/
execute(query: string, options?: SearchExecutionOptions): SearchResult[] {
if (!query || !query.trim()) {
return [];
}
const contextLines = options?.contextLines ?? 2;
const maxResults = options?.maxResults;
// Parse the query into an AST
this.logger.info('SearchOrchestrator', 'Parsing query', { query, options });
const parsed = parseSearchQuery(query, options);
if (parsed.isEmpty) {
this.logger.debug('SearchOrchestrator', 'Parsed query is empty');
return [];
}
// Convert to predicate function
this.logger.debug('SearchOrchestrator', 'Building predicate');
const predicate = queryToPredicate(parsed, options);
// Extract search terms for highlighting
this.logger.debug('SearchOrchestrator', 'Extracting search terms');
const searchTerms = this.extractSearchTerms(parsed.ast);
// Evaluate against all indexed contexts
const results: SearchResult[] = [];
const allContexts = this.searchIndex.getAllContexts();
this.logger.info('SearchOrchestrator', 'Evaluating contexts', {
contextCount: allContexts.length
});
for (const context of allContexts) {
this.logger.debug('SearchOrchestrator', 'Evaluating context', {
noteId: context.noteId,
filePath: context.filePath
});
// Apply the predicate filter (if it doesn't match, skip this context)
if (!predicate(context)) {
this.logger.debug('SearchOrchestrator', 'Context filtered out', {
noteId: context.noteId
});
continue;
}
// Find matches and ranges for highlighting (may be empty for file/path/property queries)
this.logger.debug('SearchOrchestrator', 'Context matched predicate, finding highlights', {
noteId: context.noteId
});
const { matches, allRanges } = this.findMatchesWithRanges(
context,
searchTerms,
options,
contextLines
);
const score = this.calculateScore(context, query, matches);
this.logger.debug('SearchOrchestrator', 'Context scored', {
noteId: context.noteId,
matchCount: matches.length,
score
});
results.push({
noteId: context.noteId,
matches,
score,
allRanges
});
}
// Sort by score (descending)
this.logger.debug('SearchOrchestrator', 'Sorting results', {
resultCount: results.length
});
// Apply max results limit if specified
if (maxResults && maxResults > 0) {
this.logger.debug('SearchOrchestrator', 'Applying maxResults slice', { maxResults });
return results.slice(0, maxResults);
}
this.logger.info('SearchOrchestrator', 'Returning results', {
resultCount: results.length
});
return results;
}
/**
* Extract search terms from AST for highlighting
*/
private extractSearchTerms(node: any): Array<{ type: string; value: string; propertyKey?: string }> {
const terms: Array<{ type: string; value: string; propertyKey?: string }> = [];
if (node.type === 'group') {
for (const term of node.terms) {
terms.push(...this.extractSearchTerms(term));
}
} else {
// Only include terms that should be highlighted
if (!node.negated && node.value) {
terms.push({
type: node.type,
value: node.value,
propertyKey: node.propertyKey
});
}
}
return terms;
}
/**
* Find matches with precise ranges for highlighting
*/
private findMatchesWithRanges(
context: SearchContext,
searchTerms: Array<{ type: string; value: string; propertyKey?: string }>,
options?: SearchOptions,
contextLines: number = 2
): { matches: SearchMatch[]; allRanges: MatchRange[] } {
const matches: SearchMatch[] = [];
const allRanges: MatchRange[] = [];
const caseSensitive = options?.caseSensitive || false;
// Track which lines have been matched to avoid duplicates
const matchedLines = new Set<number>();
for (const term of searchTerms) {
switch (term.type) {
case 'content':
case 'text':
case 'match-case':
case 'ignore-case':
// Search in content
const contentMatches = this.findInContent(
context.content,
context.lines,
term.value,
term.type === 'match-case' ? true : (term.type === 'ignore-case' ? false : caseSensitive),
contextLines
);
matches.push(...contentMatches.matches);
allRanges.push(...contentMatches.ranges);
contentMatches.matches.forEach(m => m.line && matchedLines.add(m.line));
break;
case 'tag':
// Search in tags
const searchTag = term.value.startsWith('#') ? term.value.substring(1) : term.value;
context.tags.forEach(tag => {
const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag;
if (this.matchString(cleanTag, searchTag, false, caseSensitive)) {
matches.push({
type: 'property',
text: tag,
context: `Tag: ${tag}`,
ranges: []
});
}
});
break;
case 'task':
case 'task-todo':
case 'task-done':
// Search in tasks
context.tasks.forEach(task => {
const shouldInclude =
term.type === 'task' ||
(term.type === 'task-todo' && !task.completed) ||
(term.type === 'task-done' && task.completed);
if (shouldInclude && this.matchString(task.text, term.value, false, caseSensitive)) {
matches.push({
type: 'task',
text: term.value,
context: task.text,
line: task.line,
ranges: []
});
if (task.line) matchedLines.add(task.line);
}
});
break;
case 'section':
// Search in sections
context.sections.forEach(section => {
if (section.heading && this.matchString(section.heading, term.value, false, caseSensitive)) {
matches.push({
type: 'heading',
text: term.value,
context: section.heading,
ranges: []
});
}
});
break;
case 'property':
// Search in properties
if (term.propertyKey) {
const propValue = context.properties[term.propertyKey];
if (propValue !== undefined) {
const valueStr = Array.isArray(propValue) ? propValue.join(', ') : String(propValue);
if (!term.value || this.matchString(valueStr, term.value, false, caseSensitive)) {
matches.push({
type: 'property',
text: term.value || term.propertyKey,
context: `${term.propertyKey}: ${valueStr}`,
ranges: []
});
}
}
}
break;
case 'line':
// Search in lines
context.lines.forEach((line, index) => {
if (this.matchString(line, term.value, false, caseSensitive)) {
const lineNum = index + 1;
if (!matchedLines.has(lineNum)) {
matches.push({
type: 'content',
text: term.value,
context: line,
line: lineNum,
ranges: []
});
matchedLines.add(lineNum);
}
}
});
break;
case 'block':
// Search in blocks
context.blocks.forEach(block => {
if (this.matchString(block, term.value, false, caseSensitive)) {
const preview = block.length > 100 ? block.substring(0, 100) + '...' : block;
matches.push({
type: 'content',
text: term.value,
context: preview,
ranges: []
});
}
});
break;
}
}
// Limit matches to avoid overwhelming results
return {
matches: matches.slice(0, 20),
allRanges: allRanges.slice(0, 50)
};
}
/**
* Find matches in content with line numbers and ranges
*/
private findInContent(
content: string,
lines: string[],
searchValue: string,
caseSensitive: boolean,
contextLines: number
): { matches: SearchMatch[]; ranges: MatchRange[] } {
const matches: SearchMatch[] = [];
const ranges: MatchRange[] = [];
const searchLower = caseSensitive ? searchValue : searchValue.toLowerCase();
const contentLower = caseSensitive ? content : content.toLowerCase();
let index = 0;
const foundLines = new Set<number>();
while ((index = contentLower.indexOf(searchLower, index)) !== -1) {
const lineNum = this.getLineNumber(content, index);
if (!foundLines.has(lineNum)) {
foundLines.add(lineNum);
const startLine = Math.max(0, lineNum - 1 - contextLines);
const endLine = Math.min(lines.length - 1, lineNum - 1 + contextLines);
const contextLinesArr = lines.slice(startLine, endLine + 1);
const snippetStartOffset = this.getLineStartOffset(content, startLine);
const localStart = index - snippetStartOffset;
const localEnd = localStart + searchValue.length;
const contextText = contextLinesArr.join('\n');
matches.push({
type: 'content',
text: searchValue,
context: contextText,
line: lineNum,
ranges: [{
start: Math.max(0, localStart),
end: Math.max(localStart, localEnd),
line: lineNum,
context: 'content'
}]
});
ranges.push({
start: index,
end: index + searchValue.length,
line: lineNum,
context: 'content'
});
}
index += searchValue.length;
}
return { matches, ranges };
}
private getLineStartOffset(content: string, lineIndex: number): number {
if (lineIndex <= 0) {
return 0;
}
let position = 0;
let currentLine = 0;
while (currentLine < lineIndex && position < content.length) {
const nextNewline = content.indexOf('\n', position);
if (nextNewline === -1) {
return content.length;
}
position = nextNewline + 1;
currentLine++;
}
return position;
}
/**
* Get line number from character offset
*/
private getLineNumber(content: string, offset: number): number {
const beforeOffset = content.substring(0, offset);
return beforeOffset.split('\n').length;
}
/**
* Match a string (simple version, can be enhanced)
*/
private matchString(text: string, pattern: string, wildcard: boolean, caseSensitive: boolean): boolean {
if (!caseSensitive) {
text = text.toLowerCase();
pattern = pattern.toLowerCase();
}
if (!wildcard) {
return text.includes(pattern);
}
// Convert wildcard to regex
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp(regexPattern, caseSensitive ? '' : 'i');
return regex.test(text);
}
/**
* Calculate relevance score for a match
*/
private calculateScore(context: SearchContext, query: string, matches: SearchMatch[]): number {
let score = 0;
// Base score from number of matches
score += matches.length * 10;
// Bonus for matches in headings
const headingMatches = matches.filter(m => m.type === 'heading');
score += headingMatches.length * 20;
// Bonus for matches in file name
const queryLower = query.toLowerCase();
if (context.fileName.toLowerCase().includes(queryLower)) {
score += 50;
}
// Bonus for exact phrase matches
if (context.content.toLowerCase().includes(queryLower)) {
score += 30;
}
return score;
}
}
+1
View File
@@ -2,6 +2,7 @@ import { detectQueryType, parseSearchQuery, queryToPredicate } from './search-pa
import { SearchContext } from './search-parser.types';
const createContext = (overrides: Partial<SearchContext> = {}): SearchContext => ({
noteId: 'notes/example',
filePath: 'notes/example.md',
fileName: 'example',
fileNameWithExt: 'example.md',
+35
View File
@@ -241,6 +241,28 @@ function parseTerm(token: string, options?: SearchOptions): SearchTerm | null {
value = value.substring(1);
}
// Support property form: [key]:value (Obsidian compatibility)
if (value.startsWith('[') && value.includes(']:')) {
const closeBracket = value.indexOf(']');
if (closeBracket > 0) {
const propertyKey = value.substring(1, closeBracket);
let propertyValue = value.substring(closeBracket + 2); // after ]:
let propValueQuoted = false;
if (propertyValue.startsWith('"') && propertyValue.endsWith('"')) {
propValueQuoted = true;
propertyValue = propertyValue.substring(1, propertyValue.length - 1);
}
return {
type: 'property',
value: propertyValue,
propertyKey,
negated,
quoted: propValueQuoted,
wildcard: propertyValue.includes('*')
};
}
}
// Handle regex patterns /.../
if (value.startsWith('/') && value.endsWith('/') && value.length > 2) {
const regexPattern = value.substring(1, value.length - 1);
@@ -271,6 +293,19 @@ function parseTerm(token: string, options?: SearchOptions): SearchTerm | null {
cleanValue = cleanValue.substring(1, cleanValue.length - 1);
}
// Fallback: handle property form [key]:value (if missed previously)
if (prefix.startsWith('[') && prefix.endsWith(']')) {
const propertyKey = prefix.substring(1, prefix.length - 1);
return {
type: 'property',
value: cleanValue,
propertyKey,
negated,
quoted: valueQuoted,
wildcard: cleanValue.includes('*')
};
}
switch (prefix) {
case 'path':
return { type: 'path', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
+2
View File
@@ -51,6 +51,8 @@ export type SearchPredicate = (context: SearchContext) => boolean;
/** Context provided to search predicates */
export interface SearchContext {
/** Unique note identifier */
noteId: string;
/** File path (e.g., "folder/note.md") */
filePath: string;
/** File name without extension (e.g., "note") */
@@ -0,0 +1,193 @@
import { Injectable, signal, computed } from '@angular/core';
/**
* Search preferences per context
*/
export interface SearchPreferences {
/** Case sensitive search */
caseSensitive: boolean;
/** Regex mode enabled */
regexMode: boolean;
/** Collapse all results by default */
collapseResults: boolean;
/** Show more context around matches */
showMoreContext: boolean;
/** Number of context lines (when showMoreContext is true) */
contextLines: number;
/** Explain search terms */
explainSearchTerms: boolean;
}
/**
* Default preferences
*/
const DEFAULT_PREFERENCES: SearchPreferences = {
caseSensitive: false,
regexMode: false,
collapseResults: false,
showMoreContext: false,
contextLines: 2,
explainSearchTerms: false
};
/**
* Search preferences service
* Manages persistent search preferences per context (vault, header, graph)
*/
@Injectable({
providedIn: 'root'
})
export class SearchPreferencesService {
private readonly STORAGE_KEY = 'obsiviewer_search_preferences';
// Preferences by context
private preferencesMap = signal<Map<string, SearchPreferences>>(new Map());
constructor() {
this.loadFromStorage();
}
/**
* Get preferences for a specific context
*/
getPreferences(context: string = 'vault'): SearchPreferences {
const prefs = this.preferencesMap().get(context);
return prefs ? { ...prefs } : { ...DEFAULT_PREFERENCES };
}
/**
* Get preferences signal for a specific context
*/
getPreferencesSignal(context: string = 'vault') {
return computed(() => {
const prefs = this.preferencesMap().get(context);
return prefs ? { ...prefs } : { ...DEFAULT_PREFERENCES };
});
}
/**
* Update preferences for a specific context
*/
updatePreferences(context: string, updates: Partial<SearchPreferences>): void {
this.preferencesMap.update(map => {
const current = map.get(context) || { ...DEFAULT_PREFERENCES };
const updated = { ...current, ...updates };
map.set(context, updated);
return new Map(map);
});
this.saveToStorage();
}
/**
* Toggle a boolean preference
*/
togglePreference(
context: string,
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms'>
): void {
const current = this.getPreferences(context);
this.updatePreferences(context, {
[key]: !current[key]
});
}
/**
* Set context lines
*/
setContextLines(context: string, lines: number): void {
this.updatePreferences(context, { contextLines: Math.max(0, Math.min(10, lines)) });
}
/**
* Reset preferences for a context
*/
resetPreferences(context: string): void {
this.preferencesMap.update(map => {
map.set(context, { ...DEFAULT_PREFERENCES });
return new Map(map);
});
this.saveToStorage();
}
/**
* Reset all preferences
*/
resetAllPreferences(): void {
this.preferencesMap.set(new Map());
this.saveToStorage();
}
/**
* Load preferences from localStorage
*/
private loadFromStorage(): void {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const data = JSON.parse(stored);
const map = new Map<string, SearchPreferences>();
for (const [context, prefs] of Object.entries(data)) {
map.set(context, { ...DEFAULT_PREFERENCES, ...(prefs as any) });
}
this.preferencesMap.set(map);
}
} catch (e) {
console.error('Failed to load search preferences:', e);
}
}
/**
* Save preferences to localStorage
*/
private saveToStorage(): void {
try {
const data: Record<string, SearchPreferences> = {};
this.preferencesMap().forEach((prefs, context) => {
data[context] = prefs;
});
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error('Failed to save search preferences:', e);
}
}
/**
* Export preferences as JSON
*/
exportPreferences(): string {
const data: Record<string, SearchPreferences> = {};
this.preferencesMap().forEach((prefs, context) => {
data[context] = prefs;
});
return JSON.stringify(data, null, 2);
}
/**
* Import preferences from JSON
*/
importPreferences(json: string): boolean {
try {
const data = JSON.parse(json);
const map = new Map<string, SearchPreferences>();
for (const [context, prefs] of Object.entries(data)) {
map.set(context, { ...DEFAULT_PREFERENCES, ...(prefs as any) });
}
this.preferencesMap.set(map);
this.saveToStorage();
return true;
} catch (e) {
console.error('Failed to import search preferences:', e);
return false;
}
}
}