chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Index of vault content for graph view
|
||||
* Maintains lists of files, tags, paths, attachments for fast filtering
|
||||
*/
|
||||
export interface GraphIndexData {
|
||||
/** All files (markdown notes) */
|
||||
files: FileIndexEntry[];
|
||||
/** All unique tags found in the vault */
|
||||
tags: string[];
|
||||
/** All unique paths (folders) */
|
||||
paths: string[];
|
||||
/** All attachments (non-markdown files) */
|
||||
attachments: AttachmentIndexEntry[];
|
||||
/** Map of file ID to tags */
|
||||
fileToTags: Map<string, string[]>;
|
||||
/** Map of file ID to path */
|
||||
fileToPath: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface FileIndexEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface AttachmentIndexEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphIndexService {
|
||||
private indexData = signal<GraphIndexData>({
|
||||
files: [],
|
||||
tags: [],
|
||||
paths: [],
|
||||
attachments: [],
|
||||
fileToTags: new Map(),
|
||||
fileToPath: new Map()
|
||||
});
|
||||
|
||||
/**
|
||||
* Current index data as signal
|
||||
*/
|
||||
get index() {
|
||||
return this.indexData.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild index from vault data
|
||||
*/
|
||||
rebuildIndex(notes: any[]): void {
|
||||
const files: FileIndexEntry[] = [];
|
||||
const tagsSet = new Set<string>();
|
||||
const pathsSet = new Set<string>();
|
||||
const attachments: AttachmentIndexEntry[] = [];
|
||||
const fileToTags = new Map<string, string[]>();
|
||||
const fileToPath = new Map<string, string>();
|
||||
|
||||
for (const note of notes) {
|
||||
// Extract path components
|
||||
const filePath = note.filePath || note.path || '';
|
||||
const pathParts = filePath.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1] || '';
|
||||
const folderPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Add to paths
|
||||
if (folderPath) {
|
||||
pathsSet.add(folderPath);
|
||||
// Also add parent paths
|
||||
const parentParts = folderPath.split('/');
|
||||
for (let i = 1; i <= parentParts.length; i++) {
|
||||
pathsSet.add(parentParts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an attachment (non-markdown file)
|
||||
const isAttachment = this.isAttachmentFile(fileName);
|
||||
|
||||
if (isAttachment) {
|
||||
const extension = this.getFileExtension(fileName);
|
||||
attachments.push({
|
||||
id: note.id,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
extension
|
||||
});
|
||||
} else {
|
||||
// Regular markdown file
|
||||
const tags = note.tags || [];
|
||||
|
||||
// Add tags to set (normalize to include #)
|
||||
tags.forEach((tag: string) => {
|
||||
const normalized = tag.startsWith('#') ? tag : `#${tag}`;
|
||||
tagsSet.add(normalized);
|
||||
});
|
||||
|
||||
files.push({
|
||||
id: note.id,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
tags
|
||||
});
|
||||
|
||||
fileToTags.set(note.id, tags);
|
||||
}
|
||||
|
||||
fileToPath.set(note.id, filePath);
|
||||
}
|
||||
|
||||
// Update index
|
||||
this.indexData.set({
|
||||
files,
|
||||
tags: Array.from(tagsSet).sort(),
|
||||
paths: Array.from(pathsSet).sort(),
|
||||
attachments,
|
||||
fileToTags,
|
||||
fileToPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions based on query type
|
||||
*/
|
||||
getSuggestions(type: 'path' | 'tag' | 'file' | 'property', filter?: string): string[] {
|
||||
const index = this.indexData();
|
||||
const filterLower = filter?.toLowerCase() || '';
|
||||
|
||||
switch (type) {
|
||||
case 'path':
|
||||
return index.paths.filter(p => !filter || p.toLowerCase().includes(filterLower));
|
||||
|
||||
case 'tag':
|
||||
return index.tags.filter(t => !filter || t.toLowerCase().includes(filterLower));
|
||||
|
||||
case 'file':
|
||||
return index.files
|
||||
.filter(f => !filter || f.name.toLowerCase().includes(filterLower))
|
||||
.map(f => f.name)
|
||||
.slice(0, 50); // Limit to 50 suggestions
|
||||
|
||||
case 'property':
|
||||
// Common Obsidian properties
|
||||
const commonProps = ['title', 'tags', 'aliases', 'description', 'author', 'date', 'created', 'modified'];
|
||||
return commonProps.filter(p => !filter || p.toLowerCase().includes(filterLower));
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is an attachment (non-markdown)
|
||||
*/
|
||||
private isAttachmentFile(fileName: string): boolean {
|
||||
const attachmentExtensions = [
|
||||
'pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp',
|
||||
'mp4', 'webm', 'ogv', 'mov', 'mkv',
|
||||
'mp3', 'wav', 'ogg', 'm4a', 'flac',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'zip', 'rar', '7z', 'tar', 'gz'
|
||||
];
|
||||
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return attachmentExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension (lowercase, no dot)
|
||||
*/
|
||||
private getFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot === -1) return '';
|
||||
return fileName.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# Search Module
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { SearchPanelComponent } from './components/search-panel/search-panel.component';
|
||||
|
||||
// In your component template
|
||||
<app-search-panel
|
||||
placeholder="Search in vault..."
|
||||
context="vault"
|
||||
(noteOpen)="openNote($event)"
|
||||
/>
|
||||
```
|
||||
|
||||
### Standalone Search Bar
|
||||
|
||||
```typescript
|
||||
import { SearchBarComponent } from './components/search-bar/search-bar.component';
|
||||
|
||||
<app-search-bar
|
||||
placeholder="Search..."
|
||||
context="my-context"
|
||||
(search)="onSearch($event)"
|
||||
/>
|
||||
```
|
||||
|
||||
## Supported Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `file:` | Match file name | `file:.jpg` |
|
||||
| `path:` | Match file path | `path:"Daily notes"` |
|
||||
| `content:` | Match in content | `content:"hello"` |
|
||||
| `tag:` | Search tags | `tag:#work` |
|
||||
| `line:` | Same line | `line:(mix flour)` |
|
||||
| `block:` | Same block | `block:(dog cat)` |
|
||||
| `section:` | Same section | `section:(intro)` |
|
||||
| `task:` | In tasks | `task:call` |
|
||||
| `task-todo:` | Uncompleted tasks | `task-todo:review` |
|
||||
| `task-done:` | Completed tasks | `task-done:meeting` |
|
||||
| `match-case:` | Case-sensitive | `match-case:API` |
|
||||
| `ignore-case:` | Case-insensitive | `ignore-case:test` |
|
||||
| `[property]` | Property exists | `[description]` |
|
||||
| `[property:value]` | Property value | `[status]:"draft"` |
|
||||
|
||||
## Boolean Operators
|
||||
|
||||
- **AND** (implicit): `term1 term2`
|
||||
- **OR**: `term1 OR term2`
|
||||
- **NOT**: `-term`
|
||||
- **Grouping**: `(term1 OR term2) term3`
|
||||
|
||||
## Special Syntax
|
||||
|
||||
- **Exact phrase**: `"hello world"`
|
||||
- **Wildcard**: `test*`
|
||||
- **Regex**: `/\d{4}/`
|
||||
|
||||
## Services
|
||||
|
||||
### SearchIndexService
|
||||
|
||||
Indexes vault content for fast searching.
|
||||
|
||||
```typescript
|
||||
constructor(private searchIndex: SearchIndexService) {}
|
||||
|
||||
// Rebuild index
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
|
||||
// Get suggestions
|
||||
const suggestions = this.searchIndex.getSuggestions('tag', '#');
|
||||
```
|
||||
|
||||
### SearchEvaluatorService
|
||||
|
||||
Executes search queries.
|
||||
|
||||
```typescript
|
||||
constructor(private evaluator: SearchEvaluatorService) {}
|
||||
|
||||
// Search
|
||||
const results = this.evaluator.search('tag:#work', {
|
||||
caseSensitive: false
|
||||
});
|
||||
```
|
||||
|
||||
### SearchAssistantService
|
||||
|
||||
Provides autocomplete and suggestions.
|
||||
|
||||
```typescript
|
||||
constructor(private assistant: SearchAssistantService) {}
|
||||
|
||||
// Get filtered options
|
||||
const options = this.assistant.getFilteredOptions('pa');
|
||||
|
||||
// Get contextual suggestions
|
||||
const suggestions = this.assistant.getSuggestions('path:proj');
|
||||
```
|
||||
|
||||
### SearchHistoryService
|
||||
|
||||
Manages search history.
|
||||
|
||||
```typescript
|
||||
constructor(private history: SearchHistoryService) {}
|
||||
|
||||
// Add to history
|
||||
this.history.add('vault', 'my query');
|
||||
|
||||
// Get history
|
||||
const items = this.history.list('vault');
|
||||
|
||||
// Clear history
|
||||
this.history.clear('vault');
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### SearchBarComponent
|
||||
|
||||
Main search input with Aa and .* buttons.
|
||||
|
||||
**Inputs:**
|
||||
- `placeholder: string` - Placeholder text
|
||||
- `context: string` - History context
|
||||
- `showSearchIcon: boolean` - Show search icon
|
||||
- `inputClass: string` - Custom CSS classes
|
||||
- `initialQuery: string` - Initial query value
|
||||
|
||||
**Outputs:**
|
||||
- `search: { query: string; options: SearchOptions }` - Search event
|
||||
- `queryChange: string` - Query change event
|
||||
|
||||
### SearchResultsComponent
|
||||
|
||||
Displays search results with grouping.
|
||||
|
||||
**Inputs:**
|
||||
- `results: SearchResult[]` - Search results
|
||||
|
||||
**Outputs:**
|
||||
- `noteOpen: { noteId: string; line?: number }` - Note open event
|
||||
|
||||
### SearchPanelComponent
|
||||
|
||||
Complete search UI (bar + results).
|
||||
|
||||
**Inputs:**
|
||||
- `placeholder: string` - Placeholder text
|
||||
- `context: string` - History context
|
||||
|
||||
**Outputs:**
|
||||
- `noteOpen: { noteId: string; line?: number }` - Note open event
|
||||
|
||||
## Examples
|
||||
|
||||
### Complex Query
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
path:projects/
|
||||
tag:#active
|
||||
(Python OR JavaScript)
|
||||
-deprecated
|
||||
file:".md"
|
||||
match-case:"API"
|
||||
`;
|
||||
|
||||
const results = this.evaluator.search(query, {
|
||||
caseSensitive: false,
|
||||
regexMode: false
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Search Context
|
||||
|
||||
```typescript
|
||||
import { SearchContext } from './search-parser.types';
|
||||
|
||||
const context: SearchContext = {
|
||||
filePath: 'notes/test.md',
|
||||
fileName: 'test',
|
||||
fileNameWithExt: 'test.md',
|
||||
content: 'Hello world',
|
||||
tags: ['#test'],
|
||||
properties: { status: 'draft' },
|
||||
lines: ['Hello world'],
|
||||
blocks: ['Hello world'],
|
||||
sections: [{ heading: 'Title', content: 'Hello world', level: 1 }],
|
||||
tasks: [{ text: 'Do something', completed: false, line: 1 }]
|
||||
};
|
||||
|
||||
const predicate = queryToPredicate(parsed);
|
||||
const matches = predicate(context);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Debounce input**: Avoid searching on every keystroke
|
||||
2. **Limit results**: Cap results at reasonable number (e.g., 100)
|
||||
3. **Incremental indexing**: Update index incrementally instead of full rebuild
|
||||
4. **Lazy loading**: Load results as user scrolls
|
||||
|
||||
## Testing
|
||||
|
||||
```typescript
|
||||
import { parseSearchQuery } from './search-parser';
|
||||
|
||||
describe('Search Parser', () => {
|
||||
it('should parse file operator', () => {
|
||||
const parsed = parseSearchQuery('file:test.md');
|
||||
expect(parsed.isEmpty).toBe(false);
|
||||
// Assert AST structure
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { detectQueryType } from './search-parser';
|
||||
import { SearchIndexService } from './search-index.service';
|
||||
|
||||
/**
|
||||
* Search option definition
|
||||
*/
|
||||
export interface SearchOption {
|
||||
prefix: string;
|
||||
description: string;
|
||||
example?: string;
|
||||
category: 'field' | 'scope' | 'modifier' | 'property';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search assistant service
|
||||
* Provides intelligent suggestions and autocomplete for search queries
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchAssistantService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
|
||||
/**
|
||||
* All available search options
|
||||
*/
|
||||
private readonly allOptions: SearchOption[] = [
|
||||
// Field operators
|
||||
{ prefix: 'file:', description: 'Match in file name', example: 'file:.jpg', category: 'field' },
|
||||
{ prefix: 'path:', description: 'Match in file path', example: 'path:"Daily notes"', category: 'field' },
|
||||
{ prefix: 'content:', description: 'Match in content', example: 'content:"happy cat"', category: 'field' },
|
||||
{ prefix: 'tag:', description: 'Search for tags', example: 'tag:#work', category: 'field' },
|
||||
|
||||
// Scope operators
|
||||
{ prefix: 'line:', description: 'Keywords on same line', example: 'line:(mix flour)', category: 'scope' },
|
||||
{ prefix: 'block:', description: 'Keywords in same block', example: 'block:(dog cat)', category: 'scope' },
|
||||
{ prefix: 'section:', description: 'Keywords under same heading', example: 'section:(dog cat)', category: 'scope' },
|
||||
|
||||
// Task operators
|
||||
{ prefix: 'task:', description: 'Search in tasks', example: 'task:call', category: 'field' },
|
||||
{ prefix: 'task-todo:', description: 'Search uncompleted tasks', example: 'task-todo:call', category: 'field' },
|
||||
{ prefix: 'task-done:', description: 'Search completed tasks', example: 'task-done:call', category: 'field' },
|
||||
|
||||
// Case modifiers
|
||||
{ prefix: 'match-case:', description: 'Case-sensitive search', example: 'match-case:HappyCat', category: 'modifier' },
|
||||
{ prefix: 'ignore-case:', description: 'Case-insensitive search', example: 'ignore-case:ikea', category: 'modifier' },
|
||||
|
||||
// Property
|
||||
{ prefix: '[property]', description: 'Match frontmatter property', example: '[description]:"page"', category: 'property' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get filtered search options based on current input
|
||||
*/
|
||||
getFilteredOptions(query: string): SearchOption[] {
|
||||
if (!query || !query.trim()) {
|
||||
return this.allOptions;
|
||||
}
|
||||
|
||||
const lastToken = this.getLastToken(query);
|
||||
if (!lastToken) {
|
||||
return this.allOptions;
|
||||
}
|
||||
|
||||
const lowerToken = lastToken.toLowerCase();
|
||||
|
||||
// Filter options that start with or contain the token
|
||||
return this.allOptions.filter(option => {
|
||||
const prefix = option.prefix.toLowerCase();
|
||||
return prefix.startsWith(lowerToken) || prefix.includes(lowerToken);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual suggestions based on the current query
|
||||
*/
|
||||
getSuggestions(query: string): { type: string; items: string[] } {
|
||||
const detected = detectQueryType(query);
|
||||
|
||||
switch (detected.type) {
|
||||
case 'path':
|
||||
return {
|
||||
type: 'Paths',
|
||||
items: this.searchIndex.getSuggestions('path', detected.value)
|
||||
};
|
||||
|
||||
case 'file':
|
||||
return {
|
||||
type: 'Files',
|
||||
items: this.searchIndex.getSuggestions('file', detected.value)
|
||||
};
|
||||
|
||||
case 'tag':
|
||||
return {
|
||||
type: 'Tags',
|
||||
items: this.searchIndex.getSuggestions('tag', detected.value)
|
||||
};
|
||||
|
||||
case 'section':
|
||||
return {
|
||||
type: 'Headings',
|
||||
items: this.searchIndex.getSuggestions('section', detected.value)
|
||||
};
|
||||
|
||||
case 'task':
|
||||
case 'task-todo':
|
||||
case 'task-done':
|
||||
return {
|
||||
type: 'Task keywords',
|
||||
items: this.searchIndex.getSuggestions('task', detected.value)
|
||||
};
|
||||
|
||||
case 'property':
|
||||
if (detected.prefix) {
|
||||
// Suggesting property values
|
||||
return {
|
||||
type: `Values for "${detected.prefix}"`,
|
||||
items: this.searchIndex.getSuggestions('property', detected.value, detected.prefix)
|
||||
};
|
||||
} else {
|
||||
// Suggesting property keys
|
||||
return {
|
||||
type: 'Properties',
|
||||
items: this.searchIndex.getSuggestions('property', detected.value)
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { type: '', items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an option into the query at the cursor position
|
||||
*/
|
||||
insertOption(query: string, option: string, cursorPosition: number): { newQuery: string; newCursorPosition: number } {
|
||||
const lastToken = this.getLastToken(query);
|
||||
|
||||
if (!lastToken) {
|
||||
// Insert at cursor
|
||||
const before = query.substring(0, cursorPosition);
|
||||
const after = query.substring(cursorPosition);
|
||||
const newQuery = before + option + after;
|
||||
return {
|
||||
newQuery,
|
||||
newCursorPosition: cursorPosition + option.length
|
||||
};
|
||||
}
|
||||
|
||||
// Replace last token
|
||||
const lastTokenIndex = query.lastIndexOf(lastToken);
|
||||
const before = query.substring(0, lastTokenIndex);
|
||||
const after = query.substring(lastTokenIndex + lastToken.length);
|
||||
const newQuery = before + option + after;
|
||||
|
||||
return {
|
||||
newQuery,
|
||||
newCursorPosition: before.length + option.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a suggestion value into the query
|
||||
*/
|
||||
insertSuggestion(query: string, suggestion: string): string {
|
||||
const detected = detectQueryType(query);
|
||||
|
||||
// Add quotes if the suggestion contains spaces
|
||||
const needsQuotes = suggestion.includes(' ') || suggestion.includes('/');
|
||||
const quotedSuggestion = needsQuotes ? `"${suggestion}"` : suggestion;
|
||||
|
||||
switch (detected.type) {
|
||||
case 'path':
|
||||
return `path:${quotedSuggestion}`;
|
||||
|
||||
case 'file':
|
||||
return `file:${quotedSuggestion}`;
|
||||
|
||||
case 'tag':
|
||||
// Ensure tag starts with #
|
||||
const tagValue = suggestion.startsWith('#') ? suggestion : `#${suggestion}`;
|
||||
return `tag:${tagValue}`;
|
||||
|
||||
case 'section':
|
||||
return `section:${quotedSuggestion}`;
|
||||
|
||||
case 'task':
|
||||
return `task:${quotedSuggestion}`;
|
||||
|
||||
case 'task-todo':
|
||||
return `task-todo:${quotedSuggestion}`;
|
||||
|
||||
case 'task-done':
|
||||
return `task-done:${quotedSuggestion}`;
|
||||
|
||||
case 'property':
|
||||
if (detected.prefix) {
|
||||
// Inserting property value
|
||||
return `[${detected.prefix}:${quotedSuggestion}]`;
|
||||
} else {
|
||||
// Inserting property key
|
||||
return `[${suggestion}]`;
|
||||
}
|
||||
|
||||
default:
|
||||
return suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last token from the query
|
||||
*/
|
||||
private getLastToken(query: string): string {
|
||||
const trimmed = query.trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
return tokens[tokens.length - 1] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get help text for a specific operator
|
||||
*/
|
||||
getHelpText(operator: string): string {
|
||||
const option = this.allOptions.find(o => o.prefix === operator);
|
||||
if (option) {
|
||||
return `${option.description}${option.example ? ` (e.g., ${option.example})` : ''}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a search query
|
||||
*/
|
||||
validateQuery(query: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for unmatched quotes
|
||||
const quoteCount = (query.match(/"/g) || []).length;
|
||||
if (quoteCount % 2 !== 0) {
|
||||
errors.push('Unmatched quote');
|
||||
}
|
||||
|
||||
// Check for unmatched parentheses
|
||||
let parenDepth = 0;
|
||||
for (const char of query) {
|
||||
if (char === '(') parenDepth++;
|
||||
if (char === ')') parenDepth--;
|
||||
if (parenDepth < 0) {
|
||||
errors.push('Unmatched closing parenthesis');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parenDepth > 0) {
|
||||
errors.push('Unmatched opening parenthesis');
|
||||
}
|
||||
|
||||
// Check for unmatched brackets
|
||||
const openBrackets = (query.match(/\[/g) || []).length;
|
||||
const closeBrackets = (query.match(/\]/g) || []).length;
|
||||
if (openBrackets !== closeBrackets) {
|
||||
errors.push('Unmatched bracket');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { parseSearchQuery, queryToPredicate } from './search-parser';
|
||||
import { SearchOptions } from './search-parser.types';
|
||||
import { SearchIndexService } from './search-index.service';
|
||||
|
||||
/**
|
||||
* Result of a search query
|
||||
*/
|
||||
export interface SearchResult {
|
||||
noteId: string;
|
||||
matches: SearchMatch[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual match within a note
|
||||
*/
|
||||
export interface SearchMatch {
|
||||
type: 'content' | 'heading' | 'task' | 'property';
|
||||
text: string;
|
||||
context: string;
|
||||
line?: number;
|
||||
startOffset?: number;
|
||||
endOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search evaluator service
|
||||
* Executes search queries against the indexed vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchEvaluatorService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
|
||||
/**
|
||||
* Execute a search query and return matching notes
|
||||
*/
|
||||
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[] = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Service for managing search history using localStorage
|
||||
* Each search context (vault search, graph search, etc.) has its own history
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchHistoryService {
|
||||
private readonly MAX_HISTORY = 10;
|
||||
private readonly STORAGE_PREFIX = 'obsidian-search-history-';
|
||||
|
||||
/**
|
||||
* Add a query to history for a specific context
|
||||
*/
|
||||
add(context: string, query: string): void {
|
||||
if (!query || !query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.getStorageKey(context);
|
||||
const history = this.list(context);
|
||||
|
||||
// Remove if already exists (to move to top)
|
||||
const filtered = history.filter(q => q !== query);
|
||||
|
||||
// Add to beginning
|
||||
filtered.unshift(query);
|
||||
|
||||
// Limit to MAX_HISTORY
|
||||
const limited = filtered.slice(0, this.MAX_HISTORY);
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(limited));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history list for a specific context
|
||||
*/
|
||||
list(context: string): string[] {
|
||||
const key = this.getStorageKey(context);
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history for a specific context
|
||||
*/
|
||||
clear(context: string): void {
|
||||
const key = this.getStorageKey(context);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific query from history
|
||||
*/
|
||||
remove(context: string, query: string): void {
|
||||
const key = this.getStorageKey(context);
|
||||
const history = this.list(context);
|
||||
const filtered = history.filter(q => q !== query);
|
||||
localStorage.setItem(key, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage key for a context
|
||||
*/
|
||||
private getStorageKey(context: string): string {
|
||||
return `${this.STORAGE_PREFIX}${context}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { SearchContext, SectionContent, TaskInfo } from './search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Comprehensive search index for the vault
|
||||
* Indexes all content for fast searching with full Obsidian operator support
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchIndexService {
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
private indexData = signal<Map<string, SearchContext>>(new Map());
|
||||
private pathsIndex = signal<string[]>([]);
|
||||
private filesIndex = signal<string[]>([]);
|
||||
private tagsIndex = signal<string[]>([]);
|
||||
private propertiesIndex = signal<Map<string, Set<string>>>(new Map());
|
||||
private headingsIndex = signal<Map<string, string[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Get all indexed paths
|
||||
*/
|
||||
get paths() {
|
||||
return this.pathsIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed files
|
||||
*/
|
||||
get files() {
|
||||
return this.filesIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed tags
|
||||
*/
|
||||
get tags() {
|
||||
return this.tagsIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed property keys
|
||||
*/
|
||||
get propertyKeys() {
|
||||
return computed(() => Array.from(this.propertiesIndex().keys()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entire search index from vault notes
|
||||
*/
|
||||
rebuildIndex(notes: Note[]): void {
|
||||
const contextMap = new Map<string, SearchContext>();
|
||||
const pathsSet = new Set<string>();
|
||||
const filesSet = new Set<string>();
|
||||
const tagsSet = new Set<string>();
|
||||
const propertiesMap = new Map<string, Set<string>>();
|
||||
const headingsMap = new Map<string, string[]>();
|
||||
|
||||
for (const note of notes) {
|
||||
// Build search context for this note
|
||||
const context = this.buildSearchContext(note);
|
||||
contextMap.set(note.id, context);
|
||||
|
||||
// Index paths
|
||||
const pathParts = context.filePath.split('/');
|
||||
for (let i = 1; i < pathParts.length; i++) {
|
||||
pathsSet.add(pathParts.slice(0, i).join('/'));
|
||||
}
|
||||
if (context.filePath) {
|
||||
pathsSet.add(context.filePath);
|
||||
}
|
||||
|
||||
// Index files
|
||||
filesSet.add(context.fileName);
|
||||
filesSet.add(context.fileNameWithExt);
|
||||
|
||||
// Index tags
|
||||
context.tags.forEach(tag => tagsSet.add(tag));
|
||||
|
||||
// Index properties
|
||||
Object.keys(context.properties).forEach(key => {
|
||||
if (!propertiesMap.has(key)) {
|
||||
propertiesMap.set(key, new Set());
|
||||
}
|
||||
const value = context.properties[key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => propertiesMap.get(key)!.add(String(v)));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
propertiesMap.get(key)!.add(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Index headings
|
||||
const headings = context.sections.map(s => s.heading).filter(h => h);
|
||||
if (headings.length > 0) {
|
||||
headingsMap.set(note.id, headings);
|
||||
}
|
||||
}
|
||||
|
||||
this.indexData.set(contextMap);
|
||||
this.pathsIndex.set(Array.from(pathsSet).sort());
|
||||
this.filesIndex.set(Array.from(filesSet).sort());
|
||||
this.tagsIndex.set(Array.from(tagsSet).sort());
|
||||
this.propertiesIndex.set(propertiesMap);
|
||||
this.headingsIndex.set(headingsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search context for a specific note
|
||||
*/
|
||||
getContext(noteId: string): SearchContext | undefined {
|
||||
return this.indexData().get(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all search contexts
|
||||
*/
|
||||
getAllContexts(): SearchContext[] {
|
||||
return Array.from(this.indexData().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions for a specific query type
|
||||
*/
|
||||
getSuggestions(
|
||||
type: 'path' | 'file' | 'tag' | 'property' | 'section' | 'task',
|
||||
filter?: string,
|
||||
propertyKey?: string
|
||||
): string[] {
|
||||
const filterLower = filter?.toLowerCase() || '';
|
||||
|
||||
switch (type) {
|
||||
case 'path':
|
||||
return this.pathsIndex()
|
||||
.filter(p => !filter || p.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'file':
|
||||
return this.filesIndex()
|
||||
.filter(f => !filter || f.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'tag':
|
||||
return this.tagsIndex()
|
||||
.filter(t => !filter || t.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'property':
|
||||
if (propertyKey) {
|
||||
// Get property values for a specific key
|
||||
const values = this.propertiesIndex().get(propertyKey);
|
||||
if (values) {
|
||||
return Array.from(values)
|
||||
.filter(v => !filter || v.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
// Get property keys
|
||||
return Array.from(this.propertiesIndex().keys())
|
||||
.filter(k => !filter || k.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
case 'section':
|
||||
// Get all unique headings
|
||||
const allHeadings = new Set<string>();
|
||||
this.headingsIndex().forEach(headings => {
|
||||
headings.forEach(h => allHeadings.add(h));
|
||||
});
|
||||
return Array.from(allHeadings)
|
||||
.filter(h => !filter || h.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'task':
|
||||
// Get common task keywords from indexed tasks
|
||||
const taskKeywords = new Set<string>();
|
||||
this.indexData().forEach(context => {
|
||||
context.tasks.forEach(task => {
|
||||
// Extract words from task text
|
||||
const words = task.text.split(/\s+/).filter(w => w.length > 2);
|
||||
words.forEach(w => taskKeywords.add(w));
|
||||
});
|
||||
});
|
||||
return Array.from(taskKeywords)
|
||||
.filter(k => !filter || k.toLowerCase().includes(filterLower))
|
||||
.slice(0, 30);
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build search context from a note
|
||||
*/
|
||||
private buildSearchContext(note: Note): SearchContext {
|
||||
const content = note.content || '';
|
||||
const rawContent = note.rawContent || content;
|
||||
|
||||
// Extract file info
|
||||
const filePath = note.filePath || note.originalPath || '';
|
||||
const fileNameWithExt = note.fileName || '';
|
||||
const fileName = fileNameWithExt.replace(/\.md$/i, '');
|
||||
|
||||
// Split content into lines
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Extract blocks (paragraphs separated by blank lines)
|
||||
const blocks = this.extractBlocks(content);
|
||||
|
||||
// Extract sections
|
||||
const sections = this.extractSections(content);
|
||||
|
||||
// Extract tasks
|
||||
const tasks = this.extractTasks(rawContent);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileName,
|
||||
fileNameWithExt,
|
||||
content,
|
||||
tags: note.tags || [],
|
||||
properties: note.frontmatter || {},
|
||||
lines,
|
||||
blocks,
|
||||
sections,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blocks (paragraphs) from content
|
||||
*/
|
||||
private extractBlocks(content: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let currentBlock = '';
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') {
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = '';
|
||||
}
|
||||
} else {
|
||||
currentBlock += (currentBlock ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock.trim());
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sections from content
|
||||
*/
|
||||
private extractSections(content: string): SectionContent[] {
|
||||
const sections: SectionContent[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
let currentHeading = '';
|
||||
let currentLevel = 0;
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headingMatch) {
|
||||
// Save previous section
|
||||
if (currentHeading || currentContent.length > 0) {
|
||||
sections.push({
|
||||
heading: currentHeading,
|
||||
content: currentContent.join('\n'),
|
||||
level: currentLevel
|
||||
});
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentLevel = headingMatch[1].length;
|
||||
currentHeading = headingMatch[2].trim();
|
||||
currentContent = [];
|
||||
} else {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last section
|
||||
if (currentHeading || currentContent.length > 0) {
|
||||
sections.push({
|
||||
heading: currentHeading,
|
||||
content: currentContent.join('\n'),
|
||||
level: currentLevel
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tasks from content
|
||||
*/
|
||||
private extractTasks(content: string): TaskInfo[] {
|
||||
const tasks: TaskInfo[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Match task items: - [ ] or - [x] or - [X]
|
||||
const taskMatch = line.match(/^[\s-]*\[([xX\s])\]\s*(.+)$/);
|
||||
if (taskMatch) {
|
||||
const completed = taskMatch[1].toLowerCase() === 'x';
|
||||
const text = taskMatch[2].trim();
|
||||
tasks.push({
|
||||
text,
|
||||
completed,
|
||||
line: index + 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { detectQueryType, parseSearchQuery, queryToPredicate } from './search-parser';
|
||||
import { SearchContext } from './search-parser.types';
|
||||
|
||||
type TestFn = () => void;
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const testResults: TestResult[] = [];
|
||||
|
||||
const runTest = (name: string, fn: TestFn): void => {
|
||||
try {
|
||||
fn();
|
||||
testResults.push({ name });
|
||||
} catch (error) {
|
||||
testResults.push({ name, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
console.error(`[search-parser spec] ${name} failed`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const assert = (condition: boolean, message: string): void => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = <T>(actual: T, expected: T, message: string): void => {
|
||||
assert(actual === expected, `${message} (expected ${expected}, received ${actual})`);
|
||||
};
|
||||
|
||||
const createContext = (overrides: Partial<SearchContext> = {}): SearchContext => ({
|
||||
filePath: 'notes/example.md',
|
||||
fileName: 'example',
|
||||
fileNameWithExt: 'example.md',
|
||||
content: 'Hello world with #tag in section heading',
|
||||
tags: ['#tag'],
|
||||
properties: { author: 'Ada', status: 'draft' },
|
||||
lines: ['Hello world with #tag', 'Another line'],
|
||||
blocks: ['Hello world with #tag in section heading'],
|
||||
sections: [{ heading: 'Section', content: 'Hello world', level: 1 }],
|
||||
tasks: [
|
||||
{ text: 'Call Bob', completed: false, line: 3 },
|
||||
{ text: 'Review PR', completed: true, line: 5 }
|
||||
],
|
||||
...overrides
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery marks empty query as empty', () => {
|
||||
const parsed = parseSearchQuery('');
|
||||
assert(parsed.isEmpty, 'Empty query should be flagged as empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Empty query AST should be an empty group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles basic text queries', () => {
|
||||
const parsed = parseSearchQuery('hello world');
|
||||
assert(!parsed.isEmpty, 'Non-empty query should not be marked empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Text query AST should be a group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles operators', () => {
|
||||
const parsed = parseSearchQuery('path:notes/ tag:#tag file:example');
|
||||
assert(!parsed.isEmpty, 'Operator query should parse successfully');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches simple text', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('hello'));
|
||||
assert(predicate(createContext()), 'Predicate should match when text is present');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate respects negation', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('-deprecated'));
|
||||
assert(predicate(createContext()), 'Predicate should pass when term is absent');
|
||||
assert(!predicate(createContext({ content: 'deprecated feature' })), 'Predicate should fail when negated term exists');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches task scopes', () => {
|
||||
const todoPredicate = queryToPredicate(parseSearchQuery('task-todo:Call'));
|
||||
const donePredicate = queryToPredicate(parseSearchQuery('task-done:Review'));
|
||||
assert(todoPredicate(createContext()), 'task-todo should match incomplete tasks');
|
||||
assert(donePredicate(createContext()), 'task-done should match completed tasks');
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises path prefix', () => {
|
||||
const info = detectQueryType('path:notes');
|
||||
assertEqual(info.type, 'path', 'Should recognise path prefix');
|
||||
assertEqual(info.value, 'notes', 'Should capture path value');
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises property bracket', () => {
|
||||
const info = detectQueryType('[status:');
|
||||
assertEqual(info.type, 'property', 'Should recognise property prefix');
|
||||
});
|
||||
|
||||
runTest('detectQueryType falls back to general type', () => {
|
||||
const info = detectQueryType('plain text');
|
||||
assertEqual(info.type, 'general', 'General text should return general type');
|
||||
});
|
||||
|
||||
export { testResults };
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* Obsidian-compatible search query parser
|
||||
* Full operator support: path:, file:, content:, tag:, line:, block:, section:,
|
||||
* task:, task-todo:, task-done:, match-case:, ignore-case:, [property], OR, AND, -, *, "", /regex/
|
||||
*/
|
||||
|
||||
import {
|
||||
SearchTerm,
|
||||
SearchGroup,
|
||||
SearchNode,
|
||||
ParsedQuery,
|
||||
SearchPredicate,
|
||||
SearchContext,
|
||||
SearchOptions,
|
||||
SectionContent,
|
||||
TaskInfo
|
||||
} from './search-parser.types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { SearchContext, SearchOptions, SectionContent, TaskInfo };
|
||||
|
||||
/**
|
||||
* Parse an Obsidian search query into an AST
|
||||
*/
|
||||
export function parseSearchQuery(query: string, options?: SearchOptions): ParsedQuery {
|
||||
if (!query || !query.trim()) {
|
||||
return {
|
||||
ast: { type: 'group', operator: 'AND', terms: [] },
|
||||
isEmpty: true
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const ast = parseTokens(tokens, options);
|
||||
|
||||
return {
|
||||
ast,
|
||||
isEmpty: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed query into a predicate function
|
||||
*/
|
||||
export function queryToPredicate(parsed: ParsedQuery, options?: SearchOptions): SearchPredicate {
|
||||
if (parsed.isEmpty) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
return (context: SearchContext) => evaluateNode(parsed.ast, context, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the query string
|
||||
*/
|
||||
function tokenize(query: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < query.length) {
|
||||
const char = query[i];
|
||||
|
||||
// Handle quoted strings
|
||||
if (char === '"') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
let quoted = '';
|
||||
i++;
|
||||
while (i < query.length && query[i] !== '"') {
|
||||
quoted += query[i];
|
||||
i++;
|
||||
}
|
||||
if (quoted) {
|
||||
tokens.push(`"${quoted}"`);
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regex patterns /.../
|
||||
if (char === '/' && (current === '' || current.endsWith(' '))) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
let regex = '/';
|
||||
i++;
|
||||
while (i < query.length && query[i] !== '/') {
|
||||
regex += query[i];
|
||||
i++;
|
||||
}
|
||||
if (i < query.length) {
|
||||
regex += '/';
|
||||
i++;
|
||||
}
|
||||
if (regex.length > 2) {
|
||||
tokens.push(regex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle parentheses
|
||||
if (char === '(' || char === ')') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle spaces
|
||||
if (char === ' ') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens.filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tokens into AST
|
||||
*/
|
||||
function parseTokens(tokens: string[], options?: SearchOptions): SearchNode {
|
||||
const terms: SearchNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
|
||||
// Handle OR operator
|
||||
if (token.toUpperCase() === 'OR') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle AND operator (implicit)
|
||||
if (token.toUpperCase() === 'AND') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle parentheses
|
||||
if (token === '(') {
|
||||
const { node, endIndex } = parseGroup(tokens, i + 1, options);
|
||||
terms.push(node);
|
||||
i = endIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse term
|
||||
const term = parseTerm(token, options);
|
||||
if (term) {
|
||||
terms.push(term);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Determine operator based on OR presence
|
||||
const hasOr = tokens.some(t => t.toUpperCase() === 'OR');
|
||||
const operator = hasOr ? 'OR' : 'AND';
|
||||
|
||||
return {
|
||||
type: 'group',
|
||||
operator,
|
||||
terms
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a group enclosed in parentheses
|
||||
*/
|
||||
function parseGroup(tokens: string[], startIndex: number, options?: SearchOptions): { node: SearchNode; endIndex: number } {
|
||||
const terms: SearchNode[] = [];
|
||||
let i = startIndex;
|
||||
let depth = 1;
|
||||
|
||||
while (i < tokens.length && depth > 0) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token === '(') {
|
||||
depth++;
|
||||
} else if (token === ')') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (token.toUpperCase() !== 'OR' && token.toUpperCase() !== 'AND' && token !== '(' && token !== ')') {
|
||||
const term = parseTerm(token, options);
|
||||
if (term) {
|
||||
terms.push(term);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
const hasOr = tokens.slice(startIndex, i).some(t => t.toUpperCase() === 'OR');
|
||||
const operator = hasOr ? 'OR' : 'AND';
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: 'group',
|
||||
operator,
|
||||
terms
|
||||
},
|
||||
endIndex: i
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single search term
|
||||
*/
|
||||
function parseTerm(token: string, options?: SearchOptions): SearchTerm | null {
|
||||
if (!token) return null;
|
||||
|
||||
let negated = false;
|
||||
let value = token;
|
||||
|
||||
// Handle negation
|
||||
if (value.startsWith('-')) {
|
||||
negated = true;
|
||||
value = value.substring(1);
|
||||
}
|
||||
|
||||
// Handle regex patterns /.../
|
||||
if (value.startsWith('/') && value.endsWith('/') && value.length > 2) {
|
||||
const regexPattern = value.substring(1, value.length - 1);
|
||||
return { type: 'regex', value: regexPattern, negated, quoted: false, wildcard: false };
|
||||
}
|
||||
|
||||
// Handle quoted strings
|
||||
let quoted = false;
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
quoted = true;
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
|
||||
// Handle wildcards
|
||||
const wildcard = value.includes('*');
|
||||
|
||||
// Check for prefixes
|
||||
const colonIndex = value.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const prefix = value.substring(0, colonIndex).toLowerCase();
|
||||
const searchValue = value.substring(colonIndex + 1);
|
||||
|
||||
// Remove quotes from search value if present
|
||||
let cleanValue = searchValue;
|
||||
let valueQuoted = false;
|
||||
if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
|
||||
valueQuoted = true;
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length - 1);
|
||||
}
|
||||
|
||||
switch (prefix) {
|
||||
case 'path':
|
||||
return { type: 'path', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'file':
|
||||
return { type: 'file', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'content':
|
||||
return { type: 'content', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'tag':
|
||||
return { type: 'tag', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'line':
|
||||
return { type: 'line', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'block':
|
||||
return { type: 'block', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'section':
|
||||
return { type: 'section', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task':
|
||||
return { type: 'task', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task-todo':
|
||||
return { type: 'task-todo', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task-done':
|
||||
return { type: 'task-done', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'match-case':
|
||||
return { type: 'match-case', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*'), caseSensitive: true };
|
||||
case 'ignore-case':
|
||||
return { type: 'ignore-case', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*'), caseSensitive: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for property search [property:value] or [property]
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
const inner = value.substring(1, value.length - 1);
|
||||
const propColonIndex = inner.indexOf(':');
|
||||
if (propColonIndex > 0) {
|
||||
const propertyKey = inner.substring(0, propColonIndex);
|
||||
let propertyValue = inner.substring(propColonIndex + 1);
|
||||
let propValueQuoted = false;
|
||||
|
||||
// Remove quotes from property value
|
||||
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('*')
|
||||
};
|
||||
}
|
||||
// Property existence check [property]
|
||||
return {
|
||||
type: 'property',
|
||||
value: '',
|
||||
propertyKey: inner,
|
||||
negated,
|
||||
quoted: false,
|
||||
wildcard: false
|
||||
};
|
||||
}
|
||||
|
||||
// Default: text search
|
||||
return { type: 'text', value, negated, quoted, wildcard };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a search node against context
|
||||
*/
|
||||
function evaluateNode(node: SearchNode, context: SearchContext, options?: SearchOptions): boolean {
|
||||
if (node.type === 'group') {
|
||||
return evaluateGroup(node, context, options);
|
||||
}
|
||||
return evaluateTerm(node, context, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a group node
|
||||
*/
|
||||
function evaluateGroup(group: SearchGroup, context: SearchContext, options?: SearchOptions): boolean {
|
||||
if (group.terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = group.terms.map(term => evaluateNode(term, context, options));
|
||||
|
||||
if (group.operator === 'OR') {
|
||||
return results.some(r => r);
|
||||
}
|
||||
|
||||
// AND operator (default)
|
||||
return results.every(r => r);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single term
|
||||
*/
|
||||
function evaluateTerm(term: SearchTerm, context: SearchContext, options?: SearchOptions): boolean {
|
||||
let result = false;
|
||||
|
||||
// Determine case sensitivity for this term
|
||||
const caseSensitive = term.caseSensitive !== undefined
|
||||
? term.caseSensitive
|
||||
: (options?.caseSensitive || false);
|
||||
|
||||
switch (term.type) {
|
||||
case 'path':
|
||||
result = matchString(context.filePath, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
result = matchString(context.fileName, term.value, term.wildcard, caseSensitive) ||
|
||||
matchString(context.fileNameWithExt, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'content':
|
||||
result = matchString(context.content, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'tag':
|
||||
const searchTag = term.value.startsWith('#') ? term.value.substring(1) : term.value;
|
||||
result = context.tags.some(tag => {
|
||||
const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag;
|
||||
return matchString(cleanTag, searchTag, term.wildcard, caseSensitive);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'line':
|
||||
result = context.lines.some(line => matchString(line, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
result = context.blocks.some(block => matchString(block, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'section':
|
||||
result = context.sections.some(section =>
|
||||
matchString(section.content, term.value, term.wildcard, caseSensitive) ||
|
||||
matchString(section.heading, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
result = context.tasks.some(task => matchString(task.text, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'task-todo':
|
||||
result = context.tasks.some(task =>
|
||||
!task.completed && matchString(task.text, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'task-done':
|
||||
result = context.tasks.some(task =>
|
||||
task.completed && matchString(task.text, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'match-case':
|
||||
// This is a text search with forced case sensitivity
|
||||
result = matchString(context.content, term.value, term.wildcard, true);
|
||||
break;
|
||||
|
||||
case 'ignore-case':
|
||||
// This is a text search with forced case insensitivity
|
||||
result = matchString(context.content, term.value, term.wildcard, false);
|
||||
break;
|
||||
|
||||
case 'regex':
|
||||
try {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(term.value, flags);
|
||||
result = regex.test(context.content);
|
||||
} catch (e) {
|
||||
// Invalid regex, no match
|
||||
result = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'property':
|
||||
if (term.propertyKey) {
|
||||
const propValue = context.properties[term.propertyKey];
|
||||
if (term.value === '') {
|
||||
// Property existence check
|
||||
result = propValue !== undefined;
|
||||
} else {
|
||||
// Property value check
|
||||
if (Array.isArray(propValue)) {
|
||||
result = propValue.some(v => matchString(String(v), term.value, term.wildcard, caseSensitive));
|
||||
} else {
|
||||
result = matchString(String(propValue || ''), term.value, term.wildcard, caseSensitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
// Search in content
|
||||
result = matchString(context.content, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply negation
|
||||
return term.negated ? !result : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a string with optional wildcard support and case sensitivity
|
||||
*/
|
||||
function matchString(text: string, pattern: string, wildcard: boolean = false, caseSensitive: boolean = false): boolean {
|
||||
if (!caseSensitive) {
|
||||
const textLower = text.toLowerCase();
|
||||
const patternLower = pattern.toLowerCase();
|
||||
|
||||
if (!wildcard) {
|
||||
return textLower.includes(patternLower);
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = patternLower
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(regexPattern, 'i');
|
||||
return regex.test(text);
|
||||
} else {
|
||||
// Case sensitive matching
|
||||
if (!wildcard) {
|
||||
return text.includes(pattern);
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex (case sensitive)
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(regexPattern);
|
||||
return regex.test(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search operators/prefixes from a partial query
|
||||
* Used for autocomplete suggestions
|
||||
*/
|
||||
export function detectQueryType(query: string): {
|
||||
type: 'path' | 'file' | 'content' | 'tag' | 'line' | 'block' | 'section' | 'task' | 'task-todo' | 'task-done' | 'match-case' | 'ignore-case' | 'property' | 'general' | null;
|
||||
prefix: string;
|
||||
value: string;
|
||||
} {
|
||||
const trimmed = query.trim();
|
||||
|
||||
// Check for property
|
||||
if (trimmed.startsWith('[')) {
|
||||
const closeBracket = trimmed.indexOf(']');
|
||||
if (closeBracket === -1) {
|
||||
// Still typing property
|
||||
const inner = trimmed.substring(1);
|
||||
const colonIndex = inner.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return { type: 'property', prefix: inner.substring(0, colonIndex), value: inner.substring(colonIndex + 1) };
|
||||
}
|
||||
return { type: 'property', prefix: '', value: inner };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard prefixes
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const prefix = trimmed.substring(0, colonIndex).toLowerCase();
|
||||
const value = trimmed.substring(colonIndex + 1);
|
||||
|
||||
const validPrefixes = [
|
||||
'path', 'file', 'content', 'tag', 'line', 'block', 'section',
|
||||
'task', 'task-todo', 'task-done', 'match-case', 'ignore-case'
|
||||
];
|
||||
|
||||
if (validPrefixes.includes(prefix)) {
|
||||
return { type: prefix as any, prefix, value };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'general', prefix: '', value: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Types for Obsidian-compatible search query parsing
|
||||
* Full operator support: path:, file:, content:, tag:, line:, block:, section:,
|
||||
* task:, task-todo:, task-done:, match-case:, ignore-case:, [property], OR, AND, -, *, "", /regex/
|
||||
*/
|
||||
|
||||
export type SearchOperator = 'AND' | 'OR' | 'NOT';
|
||||
|
||||
export type SearchTermType =
|
||||
| 'text'
|
||||
| 'path'
|
||||
| 'file'
|
||||
| 'content'
|
||||
| 'tag'
|
||||
| 'line'
|
||||
| 'block'
|
||||
| 'section'
|
||||
| 'task'
|
||||
| 'task-todo'
|
||||
| 'task-done'
|
||||
| 'match-case'
|
||||
| 'ignore-case'
|
||||
| 'property'
|
||||
| 'regex';
|
||||
|
||||
export interface SearchTerm {
|
||||
type: SearchTermType;
|
||||
value: string;
|
||||
propertyKey?: string; // For property searches like [title:value]
|
||||
quoted?: boolean;
|
||||
wildcard?: boolean;
|
||||
negated?: boolean;
|
||||
caseSensitive?: boolean; // Local case sensitivity override
|
||||
}
|
||||
|
||||
export interface SearchGroup {
|
||||
type: 'group';
|
||||
operator: SearchOperator;
|
||||
terms: (SearchTerm | SearchGroup)[];
|
||||
}
|
||||
|
||||
export type SearchNode = SearchTerm | SearchGroup;
|
||||
|
||||
export interface ParsedQuery {
|
||||
ast: SearchNode;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Predicate function to test if content matches the query */
|
||||
export type SearchPredicate = (context: SearchContext) => boolean;
|
||||
|
||||
/** Context provided to search predicates */
|
||||
export interface SearchContext {
|
||||
/** File path (e.g., "folder/note.md") */
|
||||
filePath: string;
|
||||
/** File name without extension (e.g., "note") */
|
||||
fileName: string;
|
||||
/** File name with extension (e.g., "note.md") */
|
||||
fileNameWithExt: string;
|
||||
/** Content of the file */
|
||||
content: string;
|
||||
/** Tags in the file */
|
||||
tags: string[];
|
||||
/** YAML frontmatter properties */
|
||||
properties: Record<string, any>;
|
||||
/** Lines of content */
|
||||
lines: string[];
|
||||
/** Blocks of content (paragraphs separated by blank lines) */
|
||||
blocks: string[];
|
||||
/** Sections with their content (text between headings) */
|
||||
sections: SectionContent[];
|
||||
/** Tasks in the file */
|
||||
tasks: TaskInfo[];
|
||||
}
|
||||
|
||||
/** Section content with heading */
|
||||
export interface SectionContent {
|
||||
heading: string;
|
||||
content: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** Task information */
|
||||
export interface TaskInfo {
|
||||
text: string;
|
||||
completed: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Search options for case sensitivity and regex */
|
||||
export interface SearchOptions {
|
||||
/** Global case sensitivity (default: false = case-insensitive) */
|
||||
caseSensitive?: boolean;
|
||||
/** Enable regex mode */
|
||||
regexMode?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user