docs: remove deprecated integration checklists and documentation files
This commit is contained in:
@@ -176,6 +176,7 @@ export class GraphRuntimeAdapter {
|
||||
const lines = content.split('\n');
|
||||
|
||||
return {
|
||||
noteId: note?.id || note?.path || note?.filePath || note?.name || '',
|
||||
filePath: note?.filePath || note?.path || '',
|
||||
fileName: note?.name || '',
|
||||
fileNameWithExt: note?.name || '',
|
||||
|
||||
@@ -54,24 +54,39 @@ describe('GraphSelectors', () => {
|
||||
expect(result.nodes[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by tags only', () => {
|
||||
it('should include synthetic tag nodes when enabled', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, showTags: true });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(3); // nodes 1, 2, 5 have tags
|
||||
expect(result.nodes.every(n => n.tags.length > 0)).toBe(true);
|
||||
const tagNodes = result.nodes.filter(n => n.id.startsWith('tag:'));
|
||||
expect(result.nodes.length).toBe(7); // original 5 nodes + 2 tag nodes
|
||||
expect(tagNodes.length).toBe(2);
|
||||
expect(tagNodes.map(n => n.title)).toContain('#markdown');
|
||||
expect(tagNodes.map(n => n.title)).toContain('#test');
|
||||
});
|
||||
|
||||
it('should filter by attachments', () => {
|
||||
it('should add attachment nodes when contents are provided', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, showAttachments: true });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
const noteContents = signal(new Map<string, string>([
|
||||
['1', ''],
|
||||
['2', ''],
|
||||
['3', 'Check out ![[diagram.png]] in this note.'],
|
||||
['4', ''],
|
||||
['5', '']
|
||||
]));
|
||||
const filtered = createFilteredGraphData(rawData, config, noteContents);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(1);
|
||||
expect(result.nodes[0].id).toBe('3');
|
||||
const attachmentNodes = result.nodes.filter(n => n.id.startsWith('att:'));
|
||||
expect(attachmentNodes.length).toBe(1);
|
||||
expect(attachmentNodes[0].id).toBe('att:diagram.png');
|
||||
expect(result.links.some(link =>
|
||||
(link.source === '3' && link.target === 'att:diagram.png') ||
|
||||
(link.target === '3' && link.source === 'att:diagram.png')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by existing files only', () => {
|
||||
|
||||
@@ -4,17 +4,23 @@ import {
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
inject,
|
||||
ChangeDetectionStrategy
|
||||
ChangeDetectionStrategy,
|
||||
effect,
|
||||
untracked
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchBarComponent } from '../search-bar/search-bar.component';
|
||||
import { SearchResultsComponent } from '../search-results/search-results.component';
|
||||
import { SearchEvaluatorService, SearchResult } from '../../core/search/search-evaluator.service';
|
||||
import { SearchOrchestratorService, SearchResult } from '../../core/search/search-orchestrator.service';
|
||||
import { SearchIndexService } from '../../core/search/search-index.service';
|
||||
import { SearchPreferencesService } from '../../core/search/search-preferences.service';
|
||||
import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
|
||||
/**
|
||||
* Complete search panel with bar and results
|
||||
@@ -23,7 +29,7 @@ import { VaultService } from '../../services/vault.service';
|
||||
@Component({
|
||||
selector: 'app-search-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SearchBarComponent, SearchResultsComponent],
|
||||
imports: [CommonModule, FormsModule, SearchBarComponent, SearchResultsComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="flex flex-col h-full bg-bg-primary dark:bg-gray-900">
|
||||
@@ -39,6 +45,56 @@ import { VaultService } from '../../services/vault.service';
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search options toggles -->
|
||||
@if (hasSearched() && results().length > 0) {
|
||||
<div class="flex flex-col gap-3 p-4 border-b border-border dark:border-gray-700 bg-bg-muted dark:bg-gray-800">
|
||||
<!-- Collapse results toggle -->
|
||||
<label class="flex items-center justify-between cursor-pointer group">
|
||||
<span class="text-sm text-text-main dark:text-gray-200">Collapse results</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="collapseResults"
|
||||
(change)="onToggleCollapse()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-blue-500 dark:peer-checked:bg-blue-600 transition-colors"></div>
|
||||
<div class="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Show more context toggle -->
|
||||
<label class="flex items-center justify-between cursor-pointer group">
|
||||
<span class="text-sm text-text-main dark:text-gray-200">Show more context</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="showMoreContext"
|
||||
(change)="onToggleContext()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-blue-500 dark:peer-checked:bg-blue-600 transition-colors"></div>
|
||||
<div class="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Explain search terms toggle -->
|
||||
<label class="flex items-center justify-between cursor-pointer group">
|
||||
<span class="text-sm text-text-main dark:text-gray-200">Explain search terms</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="explainSearchTerms"
|
||||
(change)="onToggleExplain()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-blue-500 dark:peer-checked:bg-blue-600 transition-colors"></div>
|
||||
<div class="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@if (isSearching()) {
|
||||
@@ -59,6 +115,9 @@ import { VaultService } from '../../services/vault.service';
|
||||
} @else if (results().length > 0) {
|
||||
<app-search-results
|
||||
[results]="results()"
|
||||
[collapseAll]="collapseResults"
|
||||
[showMoreContext]="showMoreContext"
|
||||
[contextLines]="contextLines()"
|
||||
(noteOpen)="onNoteOpen($event)"
|
||||
/>
|
||||
} @else {
|
||||
@@ -85,21 +144,51 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
private searchEvaluator = inject(SearchEvaluatorService);
|
||||
private orchestrator = inject(SearchOrchestratorService);
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
private vaultService = inject(VaultService);
|
||||
private preferences = inject(SearchPreferencesService);
|
||||
private logger = inject(ClientLoggingService);
|
||||
|
||||
results = signal<SearchResult[]>([]);
|
||||
isSearching = signal(false);
|
||||
hasSearched = signal(false);
|
||||
currentQuery = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
// Build search index from vault notes
|
||||
this.rebuildIndex();
|
||||
private lastOptions: SearchOptions = {};
|
||||
|
||||
// Rebuild index when vault changes (could be optimized with incremental updates)
|
||||
// For now, we'll rebuild on init
|
||||
// UI toggles
|
||||
collapseResults = false;
|
||||
showMoreContext = false;
|
||||
explainSearchTerms = false;
|
||||
|
||||
// Computed context lines based on showMoreContext
|
||||
contextLines = computed(() => this.showMoreContext ? 5 : 2);
|
||||
|
||||
private syncIndexEffect = effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
|
||||
context: this.context,
|
||||
noteCount: notes.length
|
||||
});
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
|
||||
const query = untracked(() => this.currentQuery());
|
||||
if (query && query.trim()) {
|
||||
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
|
||||
query,
|
||||
context: this.context
|
||||
});
|
||||
this.executeSearch(query);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load preferences for this context
|
||||
const prefs = this.preferences.getPreferences(this.context);
|
||||
this.collapseResults = prefs.collapseResults;
|
||||
this.showMoreContext = prefs.showMoreContext;
|
||||
this.explainSearchTerms = prefs.explainSearchTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,29 +199,46 @@ export class SearchPanelComponent implements OnInit {
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search execution
|
||||
*/
|
||||
onSearch(event: { query: string; options: SearchOptions }): void {
|
||||
const { query, options } = event;
|
||||
private executeSearch(query: string, options?: SearchOptions): void {
|
||||
const trimmed = query?.trim() ?? '';
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
if (!trimmed) {
|
||||
this.logger.debug('SearchPanel', 'Clearing search (empty query)');
|
||||
this.isSearching.set(false);
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseOptions = options
|
||||
? { ...this.lastOptions, ...options }
|
||||
: { ...this.lastOptions };
|
||||
|
||||
this.logger.info('SearchPanel', 'Executing search', {
|
||||
query: trimmed,
|
||||
options: baseOptions,
|
||||
contextLines: this.contextLines(),
|
||||
context: this.context
|
||||
});
|
||||
|
||||
this.isSearching.set(true);
|
||||
this.currentQuery.set(query);
|
||||
|
||||
// Execute search asynchronously to avoid blocking UI
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const searchResults = this.searchEvaluator.search(query, options);
|
||||
const searchResults = this.orchestrator.execute(trimmed, {
|
||||
...baseOptions,
|
||||
contextLines: this.contextLines()
|
||||
});
|
||||
this.logger.info('SearchPanel', 'Search completed', {
|
||||
query: trimmed,
|
||||
resultCount: searchResults.length
|
||||
});
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
this.logger.error('SearchPanel', 'Search execution error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
} finally {
|
||||
@@ -141,6 +247,17 @@ export class SearchPanelComponent implements OnInit {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search execution
|
||||
*/
|
||||
onSearch(event: { query: string; options: SearchOptions }): void {
|
||||
const { query, options } = event;
|
||||
|
||||
this.currentQuery.set(query);
|
||||
this.lastOptions = { ...options };
|
||||
this.executeSearch(query, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query change (for live search if needed)
|
||||
*/
|
||||
@@ -166,10 +283,39 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
// Re-run current search if there is one
|
||||
if (this.currentQuery()) {
|
||||
this.onSearch({
|
||||
query: this.currentQuery(),
|
||||
options: {}
|
||||
});
|
||||
this.executeSearch(this.currentQuery());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle collapse results
|
||||
*/
|
||||
onToggleCollapse(): void {
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
collapseResults: this.collapseResults
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle show more context
|
||||
*/
|
||||
onToggleContext(): void {
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
showMoreContext: this.showMoreContext
|
||||
});
|
||||
|
||||
// Re-run search with new context lines
|
||||
if (this.currentQuery()) {
|
||||
this.executeSearch(this.currentQuery());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle explain search terms
|
||||
*/
|
||||
onToggleExplain(): void {
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
explainSearchTerms: this.explainSearchTerms
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ChangeDetectionStrategy,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchResult, SearchMatch } from '../../core/search/search-evaluator.service';
|
||||
import { SearchResult, SearchMatch } from '../../core/search/search-orchestrator.service';
|
||||
import { SearchHighlighterService } from '../../core/search/search-highlighter.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,7 @@ interface ResultGroup {
|
||||
matchCount: number;
|
||||
isExpanded: boolean;
|
||||
score: number;
|
||||
allRanges: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,15 +70,6 @@ type SortOption = 'relevance' | 'name' | 'modified';
|
||||
<option value="name">Name</option>
|
||||
<option value="modified">Modified</option>
|
||||
</select>
|
||||
|
||||
<!-- Expand/Collapse all -->
|
||||
<button
|
||||
(click)="toggleAllGroups()"
|
||||
class="btn-standard-xs"
|
||||
title="Expand/Collapse all"
|
||||
>
|
||||
{{ allExpanded() ? 'Collapse all' : 'Expand all' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,8 +158,8 @@ type SortOption = 'relevance' | 'name' | 'modified';
|
||||
</div>
|
||||
|
||||
<!-- Match context with highlighting -->
|
||||
<div class="text-sm text-text-main dark:text-gray-200 font-mono leading-relaxed">
|
||||
<span [innerHTML]="highlightMatch(match.context, match.text)"></span>
|
||||
<div class="text-sm text-text-main dark:text-gray-200 font-mono leading-relaxed whitespace-pre-wrap">
|
||||
<span [innerHTML]="highlightMatch(match)"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -205,16 +199,32 @@ type SortOption = 'relevance' | 'name' | 'modified';
|
||||
})
|
||||
export class SearchResultsComponent {
|
||||
@Input() set results(value: SearchResult[]) {
|
||||
this._results.set(value);
|
||||
this.buildGroups(value);
|
||||
}
|
||||
|
||||
@Input() collapseAll: boolean = false;
|
||||
@Input() showMoreContext: boolean = false;
|
||||
@Input() contextLines: number = 2;
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
private highlighter = inject(SearchHighlighterService);
|
||||
|
||||
private _results = signal<SearchResult[]>([]);
|
||||
private groups = signal<ResultGroup[]>([]);
|
||||
sortBy: SortOption = 'relevance';
|
||||
|
||||
constructor() {
|
||||
// Watch for collapseAll changes
|
||||
effect(() => {
|
||||
if (this.collapseAll !== undefined) {
|
||||
this.applyCollapseAll(this.collapseAll);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of result files
|
||||
*/
|
||||
@@ -273,14 +283,24 @@ export class SearchResultsComponent {
|
||||
filePath,
|
||||
matches: result.matches,
|
||||
matchCount: result.matches.length,
|
||||
isExpanded: true, // Expand by default
|
||||
score: result.score
|
||||
isExpanded: !this.collapseAll, // Respect collapseAll setting
|
||||
score: result.score,
|
||||
allRanges: result.allRanges || []
|
||||
};
|
||||
});
|
||||
|
||||
this.groups.set(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply collapse all setting to all groups
|
||||
*/
|
||||
private applyCollapseAll(collapse: boolean): void {
|
||||
this.groups.update(groups => {
|
||||
return groups.map(g => ({ ...g, isExpanded: !collapse }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a result group
|
||||
*/
|
||||
@@ -295,7 +315,7 @@ export class SearchResultsComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all groups
|
||||
* Toggle all groups (not used when controlled by parent)
|
||||
*/
|
||||
toggleAllGroups(): void {
|
||||
const shouldExpand = !this.allExpanded();
|
||||
@@ -320,30 +340,24 @@ export class SearchResultsComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matched text in context
|
||||
* Highlight matched text in context using ranges
|
||||
*/
|
||||
highlightMatch(context: string, matchText: string): string {
|
||||
if (!matchText || !context) {
|
||||
return this.escapeHtml(context);
|
||||
highlightMatch(match: SearchMatch): string {
|
||||
if (!match || !match.context) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const escapedContext = this.escapeHtml(context);
|
||||
const escapedMatch = this.escapeHtml(matchText);
|
||||
|
||||
// Case-insensitive replacement with highlighting
|
||||
const regex = new RegExp(`(${escapedMatch})`, 'gi');
|
||||
return escapedContext.replace(
|
||||
regex,
|
||||
'<mark class="bg-yellow-200 dark:bg-yellow-600 text-text-main dark:text-gray-900 px-0.5 rounded">$1</mark>'
|
||||
);
|
||||
}
|
||||
// If we have ranges, use them for precise highlighting
|
||||
if (match.ranges && match.ranges.length > 0) {
|
||||
return this.highlighter.highlightWithRanges(match.context, match.ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
// Fallback to simple text-based highlighting
|
||||
if (match.text) {
|
||||
return this.highlighter.highlightMatches(match.context, [match.text], false);
|
||||
}
|
||||
|
||||
// No highlighting needed
|
||||
return this.highlighter.highlightMatches(match.context, [], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<script>');
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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('*') };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
type ClientLogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface ClientLogPayload {
|
||||
source: string;
|
||||
level: ClientLogLevel;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ClientLoggingService {
|
||||
private http = inject(HttpClient, { optional: true });
|
||||
private endpoint = '/api/logs';
|
||||
|
||||
log(source: string, level: ClientLogLevel, message: string, data?: unknown): void {
|
||||
const payload: ClientLogPayload = {
|
||||
source,
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.logToConsole(payload);
|
||||
|
||||
if (!this.http) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.http.post(this.endpoint, payload).pipe(
|
||||
catchError(error => {
|
||||
this.logToConsole({
|
||||
source: 'client-logger',
|
||||
level: 'warn',
|
||||
message: 'Failed to send client log',
|
||||
data: { original: payload, error: error?.message ?? error },
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
debug(source: string, message: string, data?: unknown): void {
|
||||
this.log(source, 'debug', message, data);
|
||||
}
|
||||
|
||||
info(source: string, message: string, data?: unknown): void {
|
||||
this.log(source, 'info', message, data);
|
||||
}
|
||||
|
||||
warn(source: string, message: string, data?: unknown): void {
|
||||
this.log(source, 'warn', message, data);
|
||||
}
|
||||
|
||||
error(source: string, message: string, data?: unknown): void {
|
||||
this.log(source, 'error', message, data);
|
||||
}
|
||||
|
||||
private logToConsole(payload: ClientLogPayload): void {
|
||||
const { source, level, message, data, timestamp } = payload;
|
||||
const prefix = `[${source}] ${new Date(timestamp).toISOString()}`;
|
||||
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(prefix, message, data ?? '');
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(prefix, message, data ?? '');
|
||||
break;
|
||||
case 'debug':
|
||||
console.debug(prefix, message, data ?? '');
|
||||
break;
|
||||
default:
|
||||
console.log(prefix, message, data ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user