Merge pull request #35 from gamosoft/features/tagging

- Added TAGs with YAML frontmatter.
- Fixed URL location bug not replacing when entering a note in the path.
- Updated screenshot to show tags.
This commit is contained in:
Gamosoft 2025-11-24 17:27:50 +01:00 committed by GitHub
commit f941d6e9cd
8 changed files with 798 additions and 37 deletions

View File

@ -206,6 +206,7 @@ Want to learn more?
- 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes
- ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts
- 🏷️ **[TAGS.md](documentation/TAGS.md)** - Organize notes with tags and combined filtering
- 🧮 **[MATHJAX.md](documentation/MATHJAX.md)** - LaTeX/Math notation examples and syntax reference
- 📊 **[MERMAID.md](documentation/MERMAID.md)** - Diagram creation with Mermaid (flowcharts, sequence diagrams, and more)
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins

View File

@ -33,6 +33,8 @@ from .utils import (
delete_folder,
save_uploaded_image,
validate_path_security,
get_all_tags,
get_notes_by_tag,
)
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
@ -325,6 +327,19 @@ async def api_documentation():
"body": {"oldPath": "Current folder path", "newPath": "New folder path"},
"response": "{ success, oldPath, newPath }"
},
{
"method": "GET",
"path": "/api/tags",
"description": "Get all tags used across all notes with their counts",
"response": "{ tags: { tag_name: count, ... } }"
},
{
"method": "GET",
"path": "/api/tags/{tag_name}",
"description": "Get all notes that have a specific tag",
"parameters": {"tag_name": "Tag to filter by (case-insensitive)"},
"response": "{ tag, count, notes: [{ path, name, folder, tags }] }"
},
{
"method": "GET",
"path": "/api/search",
@ -626,6 +641,47 @@ async def delete_folder_endpoint(folder_path: str):
raise HTTPException(status_code=500, detail=str(e))
# --- Tags Endpoints ---
@api_router.get("/tags")
async def list_tags():
"""
Get all tags used across all notes with their counts.
Returns:
Dictionary mapping tag names to note counts
"""
try:
tags = get_all_tags(config['storage']['notes_dir'])
return {"tags": tags}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/tags/{tag_name}")
async def get_notes_by_tag_endpoint(tag_name: str):
"""
Get all notes that have a specific tag.
Args:
tag_name: The tag to filter by (case-insensitive)
Returns:
List of notes matching the tag
"""
try:
notes = get_notes_by_tag(config['storage']['notes_dir'], tag_name)
return {
"tag": tag_name,
"count": len(notes),
"notes": notes
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# --- Notes Endpoints ---
@api_router.get("/notes")
async def list_notes():
"""List all notes with metadata"""

View File

@ -6,10 +6,15 @@ import os
import re
import shutil
from pathlib import Path
from typing import List, Dict, Optional
from typing import List, Dict, Optional, Tuple
from datetime import datetime
# In-memory cache for parsed tags
# Format: {file_path: (mtime, tags)}
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
def validate_path_security(notes_dir: str, path: Path) -> bool:
"""
Validate that a path is within the notes directory (security check).
@ -81,6 +86,11 @@ def move_note(notes_dir: str, old_path: str, new_path: str) -> bool:
if not old_full_path.exists():
return False
# Invalidate cache for old path
old_key = str(old_full_path)
if old_key in _tag_cache:
del _tag_cache[old_key]
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
@ -111,6 +121,13 @@ def move_folder(notes_dir: str, old_path: str, new_path: str) -> bool:
if new_full_path.exists():
return False
# Invalidate cache for all notes in this folder
global _tag_cache
old_path_str = str(old_full_path)
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(old_path_str)]
for key in keys_to_delete:
del _tag_cache[key]
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
@ -145,6 +162,13 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:
print(f"Path is not a directory: {full_path}")
return False
# Invalidate cache for all notes in this folder
global _tag_cache
folder_path_str = str(full_path)
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(folder_path_str)]
for key in keys_to_delete:
del _tag_cache[key]
# Delete the folder and all its contents
shutil.rmtree(full_path)
print(f"Successfully deleted folder: {full_path}")
@ -166,13 +190,17 @@ def get_all_notes(notes_dir: str) -> List[Dict]:
relative_path = md_file.relative_to(notes_path)
stat = md_file.stat()
# Get tags for this note (cached)
tags = get_tags_cached(md_file)
items.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"size": stat.st_size,
"type": "note"
"type": "note",
"tags": tags
})
# Get all images
@ -229,6 +257,11 @@ def delete_note(notes_dir: str, note_path: str) -> bool:
if not validate_path_security(notes_dir, full_path):
return False
# Invalidate cache for this note
file_key = str(full_path)
if file_key in _tag_cache:
del _tag_cache[file_key]
full_path.unlink()
# Note: We don't automatically delete empty folders to preserve user's folder structure
@ -237,7 +270,10 @@ def delete_note(notes_dir: str, note_path: str) -> bool:
def search_notes(notes_dir: str, query: str) -> List[Dict]:
"""Simple full-text search through all notes"""
"""
Full-text search through note contents only.
Does NOT search in file names, folder names, or paths - only note content.
"""
results = []
query_lower = query.lower()
notes_path = Path(notes_dir)
@ -246,7 +282,8 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
try:
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
# Only search in note content (not file name, folder name, or path)
if query_lower in content.lower():
# Find context around match
lines = content.split('\n')
@ -267,6 +304,7 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
results.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"matches": matched_lines[:3] # Limit to 3 matches per file
})
except Exception:
@ -419,3 +457,193 @@ def get_all_images(notes_dir: str) -> List[Dict]:
return images
def parse_tags(content: str) -> List[str]:
"""
Extract tags from YAML frontmatter in markdown content.
Supported formats:
---
tags: [python, tutorial, backend]
---
or
---
tags:
- python
- tutorial
- backend
---
Args:
content: Markdown content with optional YAML frontmatter
Returns:
List of tag strings (lowercase, no duplicates)
"""
tags = []
# Check if content starts with frontmatter
if not content.strip().startswith('---'):
return tags
try:
# Extract frontmatter (between first two --- markers)
lines = content.split('\n')
if lines[0].strip() != '---':
return tags
# Find closing ---
end_idx = None
for i in range(1, len(lines)):
if lines[i].strip() == '---':
end_idx = i
break
if end_idx is None:
return tags
frontmatter_lines = lines[1:end_idx]
# Parse tags field
in_tags_list = False
for line in frontmatter_lines:
stripped = line.strip()
# Check for inline array format: tags: [tag1, tag2, tag3]
if stripped.startswith('tags:'):
rest = stripped[5:].strip()
if rest.startswith('[') and rest.endswith(']'):
# Parse inline array
tags_str = rest[1:-1] # Remove [ and ]
raw_tags = [t.strip() for t in tags_str.split(',')]
tags.extend([t.lower() for t in raw_tags if t])
break
elif rest:
# Single tag without brackets
tags.append(rest.lower())
break
else:
# Multi-line list format
in_tags_list = True
elif in_tags_list:
if stripped.startswith('-'):
# List item
tag = stripped[1:].strip()
if tag:
tags.append(tag.lower())
elif stripped and not stripped.startswith('#'):
# End of tags list
break
# Remove duplicates and return
return sorted(list(set(tags)))
except Exception as e:
# If parsing fails, return empty list
print(f"Error parsing tags: {e}")
return []
def get_tags_cached(file_path: Path) -> List[str]:
"""
Get tags for a file with caching based on modification time.
Args:
file_path: Path to the markdown file
Returns:
List of tags from the file (cached if mtime unchanged)
"""
global _tag_cache
try:
# Get current modification time
mtime = file_path.stat().st_mtime
file_key = str(file_path)
# Check cache
if file_key in _tag_cache:
cached_mtime, cached_tags = _tag_cache[file_key]
if cached_mtime == mtime:
# Cache hit! Return cached tags
return cached_tags
# Cache miss or stale - parse tags
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
tags = parse_tags(content)
# Update cache
_tag_cache[file_key] = (mtime, tags)
return tags
except Exception:
# If anything fails, return empty list
return []
def clear_tag_cache():
"""Clear the tag cache (useful for testing or manual cache invalidation)"""
global _tag_cache
_tag_cache.clear()
def get_all_tags(notes_dir: str) -> Dict[str, int]:
"""
Get all tags used across all notes with their count (cached).
Args:
notes_dir: Directory containing notes
Returns:
Dictionary mapping tag names to note counts
"""
tag_counts = {}
notes_path = Path(notes_dir)
for md_file in notes_path.rglob("*.md"):
# Get tags using cache
tags = get_tags_cached(md_file)
for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return dict(sorted(tag_counts.items()))
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
"""
Get all notes that have a specific tag (cached).
Args:
notes_dir: Directory containing notes
tag: Tag to filter by (case-insensitive)
Returns:
List of note dictionaries matching the tag
"""
matching_notes = []
tag_lower = tag.lower()
notes_path = Path(notes_dir)
for md_file in notes_path.rglob("*.md"):
# Get tags using cache
tags = get_tags_cached(md_file)
if tag_lower in tags:
relative_path = md_file.relative_to(notes_path)
stat = md_file.stat()
matching_notes.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"size": stat.st_size,
"tags": tags
})
return matching_notes

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 448 KiB

View File

@ -73,14 +73,39 @@
- **Event hooks** - React to note saves, deletes, searches
- **API access** - Full access to backend functionality
## 🔍 Search
## 🏷️ Tags
- **Full-text search** - Find notes by content
Organize notes with tags defined in YAML frontmatter. See **[TAGS.md](TAGS.md)** for complete guide.
### Quick Start
```markdown
---
tags: [python, tutorial, backend]
---
# Your Note Content
```
### Features
- **Click to filter** - Select tags to show matching notes
- **Multiple tags** - Combine tags (AND logic - all must match)
- **Tag counts** - See how many notes use each tag
- **Collapsible panel** - Saves state across sessions
- **Auto-sync** - Updates after saving notes
## 🔍 Search & Filtering
### Text Search
- **Content-only** - Searches note contents (not file/folder names)
- **Real-time results** - As you type
- **Highlight matches** - See context in results
- **In-note highlighting** - Search terms highlighted in open notes
- **Live highlighting** - Highlights update as you type or edit
- **Fast indexing** - Instant search across notes
### Combined Filtering
- **Tags + Search** - Combine text search with tag filters
- **Smart display** - Shows flat list when filtering, tree view when browsing
- **Empty states** - Clear "no matches" message with quick actions
## 🧮 Math & LaTeX Support

111
documentation/TAGS.md Normal file
View File

@ -0,0 +1,111 @@
# 🏷️ Tags
## Overview
Organize and filter your notes using tags defined in YAML frontmatter.
## Basic Usage
Add tags to the top of your note:
```markdown
---
tags: [python, tutorial]
---
# Your Note Content
The rest of your note goes here...
```
## Syntax Formats
### Inline Array (Recommended)
```yaml
---
tags: [python, tutorial, backend]
---
```
### Multi-line List
```yaml
---
tags:
- python
- tutorial
- backend
---
```
### Single Tag
```yaml
---
tags: python
---
```
## Features
### Filtering
- Click any tag in the sidebar to filter notes
- Select multiple tags to combine filters (AND logic)
- Only notes with ALL selected tags are shown
- Tag count badge shows number of notes per tag
### Combined Search
- Use tags alone to filter by category
- Use text search alone to find content
- Combine both to narrow results (e.g., search "async" in notes tagged "python")
### Display Modes
| Filter Type | Display |
|------------|---------|
| None | Full folder tree |
| Tags only | Flat list of matching notes |
| Text only | Search results with matches |
| Tags + Text | Combined filtered results |
## Tips
- **Tag names**: Lowercase, no spaces (e.g., `python`, `work-notes`)
- **Consistency**: Use consistent tag names across notes
- **Hierarchy**: Use related tags (e.g., `python`, `python-async`, `python-web`)
- **Don't overdo it**: 3-5 tags per note is usually sufficient
## Frontmatter Rules
- Must start with `---` on the first line
- Must end with `---` on its own line
- Content between markers is YAML format
- Frontmatter is hidden from preview
## Examples
### Project Organization
```markdown
---
tags: [project, backend, api]
---
# API Documentation
```
### Knowledge Base
```markdown
---
tags: [tutorial, beginner, docker]
---
# Getting Started with Docker
```
### Work Notes
```markdown
---
tags: [meeting, q4-2024, planning]
---
# Q4 Planning Meeting Notes
```

View File

@ -62,6 +62,12 @@ function noteApp() {
draggedFolder: null,
dragOverFolder: null, // Track which folder is being hovered during drag
// Tags state
allTags: {},
selectedTags: [],
tagsExpanded: false,
tagReloadTimeout: null, // For debouncing tag reloads
// Scroll sync state
isScrolling: false,
@ -256,12 +262,13 @@ function noteApp() {
this.loadSidebarWidth();
this.loadEditorWidth();
this.loadViewMode();
this.loadTagsExpanded();
// Parse URL and load specific note if provided
this.loadNoteFromURL();
// Set initial homepage state if no note is loaded
if (!this.currentNote) {
// Set initial homepage state ONLY if we're actually on the homepage
if (window.location.pathname === '/') {
window.history.replaceState({ homepageFolder: '' }, '', '/');
}
@ -340,6 +347,11 @@ function noteApp() {
}
});
// Watch tags panel expanded state and save to localStorage
this.$watch('tagsExpanded', () => {
this.saveTagsExpanded();
});
// Setup keyboard shortcuts (only once to prevent double triggers)
if (!window.__noteapp_shortcuts_initialized) {
window.__noteapp_shortcuts_initialized = true;
@ -536,11 +548,196 @@ function noteApp() {
this.notes = data.notes;
this.allFolders = data.folders || [];
this.buildFolderTree();
await this.loadTags(); // Load tags after notes are loaded
} catch (error) {
ErrorHandler.handle('load notes', error);
}
},
// Load all tags
async loadTags() {
try {
const response = await fetch('/api/tags');
const data = await response.json();
this.allTags = data.tags || {};
} catch (error) {
ErrorHandler.handle('load tags', error, false); // Don't show alert, tags are optional
}
},
// Debounced tag reload (prevents excessive API calls during typing)
loadTagsDebounced() {
// Clear existing timeout
if (this.tagReloadTimeout) {
clearTimeout(this.tagReloadTimeout);
}
// Set new timeout - reload tags 2 seconds after last save
this.tagReloadTimeout = setTimeout(() => {
this.loadTags();
}, 2000);
},
// Toggle tag selection for filtering
toggleTag(tag) {
const index = this.selectedTags.indexOf(tag);
if (index > -1) {
this.selectedTags.splice(index, 1);
} else {
this.selectedTags.push(tag);
}
// Apply unified filtering
this.applyFilters();
},
// Clear all tag filters
clearTagFilters() {
this.selectedTags = [];
// Apply unified filtering
this.applyFilters();
},
// Unified filtering logic combining tags and text search
async applyFilters() {
const hasTextSearch = this.searchQuery.trim().length > 0;
const hasTagFilter = this.selectedTags.length > 0;
// Case 1: No filters at all → show full folder tree
if (!hasTextSearch && !hasTagFilter) {
this.searchResults = [];
this.currentSearchHighlight = '';
this.clearSearchHighlights();
this.buildFolderTree();
return;
}
// Case 2: Only tag filter → convert to flat list of matching notes
if (hasTagFilter && !hasTextSearch) {
this.searchResults = this.notes.filter(note =>
note.type === 'note' && this.noteMatchesTags(note)
);
this.currentSearchHighlight = '';
this.clearSearchHighlights();
return;
}
// Case 3: Text search (with or without tag filter)
if (hasTextSearch) {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`);
const data = await response.json();
// Apply tag filtering to search results if tags are selected
let results = data.results;
if (hasTagFilter) {
results = results.filter(result => {
const note = this.notes.find(n => n.path === result.path);
return note ? this.noteMatchesTags(note) : false;
});
}
this.searchResults = results;
// Highlight search term in current note if open
if (this.currentNote && this.noteContent) {
this.currentSearchHighlight = this.searchQuery;
this.$nextTick(() => {
this.highlightSearchTerm(this.searchQuery, false);
});
}
} catch (error) {
console.error('Search failed:', error);
}
}
},
// Check if a note matches selected tags (AND logic)
noteMatchesTags(note) {
if (this.selectedTags.length === 0) {
return true; // No filter active
}
if (!note.tags || note.tags.length === 0) {
return false; // Note has no tags but filter is active
}
// Check if note has ALL selected tags (AND logic)
return this.selectedTags.every(tag => note.tags.includes(tag));
},
// Get all tags sorted by name
get sortedTags() {
return Object.entries(this.allTags).sort((a, b) => a[0].localeCompare(b[0]));
},
// Get tags for current note
get currentNoteTags() {
if (!this.currentNote) return [];
const note = this.notes.find(n => n.path === this.currentNote);
return note && note.tags ? note.tags : [];
},
// Parse tags from markdown content (matches backend logic)
parseTagsFromContent(content) {
if (!content || !content.trim().startsWith('---')) {
return [];
}
try {
const lines = content.split('\n');
if (lines[0].trim() !== '---') return [];
// Find closing ---
let endIdx = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
endIdx = i;
break;
}
}
if (endIdx === -1) return [];
const frontmatterLines = lines.slice(1, endIdx);
const tags = [];
let inTagsList = false;
for (const line of frontmatterLines) {
const stripped = line.trim();
// Check for inline array: tags: [tag1, tag2]
if (stripped.startsWith('tags:')) {
const rest = stripped.substring(5).trim();
if (rest.startsWith('[') && rest.endsWith(']')) {
const tagsStr = rest.substring(1, rest.length - 1);
const rawTags = tagsStr.split(',').map(t => t.trim());
tags.push(...rawTags.filter(t => t).map(t => t.toLowerCase()));
break;
} else if (rest) {
tags.push(rest.toLowerCase());
break;
} else {
inTagsList = true;
}
} else if (inTagsList) {
if (stripped.startsWith('-')) {
const tag = stripped.substring(1).trim();
if (tag && !tag.startsWith('#')) {
tags.push(tag.toLowerCase());
}
} else if (stripped && !stripped.startsWith('#')) {
break;
}
}
}
return [...new Set(tags)].sort();
} catch (e) {
console.error('Error parsing tags:', e);
return [];
}
},
// Build folder tree structure
buildFolderTree() {
const tree = {};
@ -565,7 +762,7 @@ function noteApp() {
});
});
// Add notes to their folders
// Add ALL notes to their folders (no filtering - tree only shown when no filters active)
this.notes.forEach(note => {
if (!note.folder) {
// Root level note
@ -2072,6 +2269,17 @@ function noteApp() {
if (note) {
note.modified = new Date().toISOString();
note.size = new Blob([this.noteContent]).size;
// Parse tags from content
note.tags = this.parseTagsFromContent(this.noteContent);
}
// Reload tags to update sidebar counts (debounced to prevent spam)
this.loadTagsDebounced();
// Rebuild folder tree if tag filters are active
if (this.selectedTags.length > 0) {
this.buildFolderTree();
}
// Hide "saved" indicator
@ -2164,30 +2372,9 @@ function noteApp() {
},
// Search notes
// Search notes by text (calls unified filter logic)
async searchNotes() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
this.currentSearchHighlight = '';
this.clearSearchHighlights();
return;
}
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`);
const data = await response.json();
this.searchResults = data.results;
// If a note is currently open, highlight the search term in real-time
if (this.currentNote && this.noteContent) {
this.currentSearchHighlight = this.searchQuery;
this.$nextTick(() => {
// Don't focus editor during real-time search (false)
this.highlightSearchTerm(this.searchQuery, false);
});
}
} catch (error) {
console.error('Search failed:', error);
}
await this.applyFilters();
},
// Trigger MathJax typesetting after DOM update
@ -2305,6 +2492,26 @@ function noteApp() {
get renderedMarkdown() {
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
// Strip YAML frontmatter from content before rendering
let contentToRender = this.noteContent;
if (contentToRender.trim().startsWith('---')) {
const lines = contentToRender.split('\n');
if (lines[0].trim() === '---') {
// Find closing ---
let endIdx = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
endIdx = i;
break;
}
}
if (endIdx !== -1) {
// Remove frontmatter (including the closing ---) and any empty lines after it
contentToRender = lines.slice(endIdx + 1).join('\n').trim();
}
}
}
// Configure marked with syntax highlighting
marked.setOptions({
breaks: true,
@ -2322,7 +2529,7 @@ function noteApp() {
});
// Parse markdown
let html = marked.parse(this.noteContent);
let html = marked.parse(contentToRender);
// Post-process: Add target="_blank" to external links and title attributes to images
// Parse as DOM to safely manipulate
@ -2721,6 +2928,25 @@ function noteApp() {
}
},
loadTagsExpanded() {
try {
const saved = localStorage.getItem('tagsExpanded');
if (saved !== null) {
this.tagsExpanded = saved === 'true';
}
} catch (error) {
console.error('Error loading tags expanded state:', error);
}
},
saveTagsExpanded() {
try {
localStorage.setItem('tagsExpanded', this.tagsExpanded.toString());
} catch (error) {
console.error('Error saving tags expanded state:', error);
}
},
// Start resizing sidebar
startResize(event) {
this.isResizing = true;

View File

@ -753,7 +753,6 @@
</select>
</div>
<p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p>
<!-- Logout Button (only show if auth is enabled) -->
<div x-data="{ authEnabled: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => authEnabled = d.security?.enabled || false)">
<a x-show="authEnabled"
@ -829,6 +828,74 @@
</div>
</div>
<!-- Tags Panel -->
<div class="border-b" style="border-color: var(--border-primary);">
<!-- Tags Header -->
<div
@click="tagsExpanded = !tagsExpanded"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-opacity-10 transition-colors"
style="background-color: var(--bg-secondary);"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" style="color: var(--text-tertiary);" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium" style="color: var(--text-primary);">Tags</span>
<span class="text-xs px-1.5 py-0.5 rounded" style="background-color: var(--bg-tertiary); color: var(--text-tertiary);" x-text="Object.keys(allTags).length"></span>
</div>
<svg
class="w-4 h-4 transform transition-transform"
:class="{'rotate-180': tagsExpanded}"
style="color: var(--text-tertiary);"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- Tags List -->
<div x-show="tagsExpanded" class="px-3 py-2 max-h-48 overflow-y-auto custom-scrollbar">
<!-- Clear Filter Button -->
<div x-show="selectedTags.length > 0" class="mb-2">
<button
@click="clearTagFilters()"
class="w-full px-2 py-1 text-xs rounded flex items-center justify-center gap-1"
style="background-color: var(--accent-primary); color: white;"
title="Clear tag filters"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Clear (<span x-text="selectedTags.length"></span>)
</button>
</div>
<!-- Tag Chips -->
<div class="flex flex-wrap gap-1.5">
<template x-for="[tag, count] in sortedTags" :key="tag">
<button
@click="toggleTag(tag)"
class="px-2 py-1 text-xs rounded-full transition-all hover:brightness-110"
:style="selectedTags.includes(tag)
? 'background-color: var(--accent-primary); color: white; font-weight: 600;'
: 'background-color: var(--bg-tertiary); color: var(--text-primary);'"
:title="`Filter by ${tag} (${count} notes)`"
>
<span x-text="tag"></span>
<span class="opacity-75" x-text="` (${count})`"></span>
</button>
</template>
</div>
<!-- Empty State -->
<div x-show="Object.keys(allTags).length === 0" class="text-xs text-center py-2" style="color: var(--text-tertiary);">
No tags found. Add tags to notes using YAML frontmatter:
</div>
</div>
</div>
<!-- New Item Dropdown -->
<div class="p-3 border-b relative" style="border-color: var(--border-primary);">
<button
@ -886,7 +953,10 @@
<div class="flex-1 overflow-y-auto custom-scrollbar">
<template x-if="searchResults.length > 0">
<div class="p-2">
<div class="text-xs px-2 py-1" style="color: var(--text-tertiary);">Search Results</div>
<div class="text-xs px-2 py-1 flex items-center justify-between" style="color: var(--text-tertiary);">
<span x-text="searchQuery.trim() ? 'Search Results' : 'Filtered Notes'"></span>
<span x-text="`${searchResults.length} note${searchResults.length === 1 ? '' : 's'}`"></span>
</div>
<template x-for="note in searchResults" :key="note.path">
<div
@click="loadNote(note.path, true, searchQuery)"
@ -902,7 +972,51 @@
</div>
</template>
<template x-if="searchResults.length === 0">
<!-- No Results Message (when filters active but no matches) -->
<template x-if="searchResults.length === 0 && (selectedTags.length > 0 || searchQuery.trim())">
<div class="p-6 text-center">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h3 class="text-lg font-medium mb-2" style="color: var(--text-primary);">No matches found</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
<template x-if="searchQuery.trim() && selectedTags.length > 0">
<span>No notes match both your search and selected tags</span>
</template>
<template x-if="searchQuery.trim() && selectedTags.length === 0">
<span>No notes contain "<span x-text="searchQuery"></span>"</span>
</template>
<template x-if="!searchQuery.trim() && selectedTags.length > 0">
<span>No notes have the selected tag<span x-text="selectedTags.length > 1 ? 's' : ''"></span></span>
</template>
</p>
<div class="flex gap-2 justify-center">
<button
x-show="searchQuery.trim()"
@click="searchQuery = ''; searchNotes();"
class="px-3 py-1.5 text-sm rounded transition-colors"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
>
Clear search
</button>
<button
x-show="selectedTags.length > 0"
@click="clearTagFilters()"
class="px-3 py-1.5 text-sm rounded transition-colors"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
>
Clear tags
</button>
</div>
</div>
</template>
<!-- Folder Tree (when no filters active) -->
<template x-if="searchResults.length === 0 && selectedTags.length === 0 && !searchQuery.trim()">
<div class="p-2">
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
<div class="flex items-center justify-between mb-1">