added tagging system
This commit is contained in:
parent
d6595831e9
commit
b56c78587e
|
|
@ -206,6 +206,7 @@ Want to learn more?
|
||||||
|
|
||||||
- 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes
|
- 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes
|
||||||
- ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts
|
- ✨ **[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
|
- 🧮 **[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)
|
- 📊 **[MERMAID.md](documentation/MERMAID.md)** - Diagram creation with Mermaid (flowcharts, sequence diagrams, and more)
|
||||||
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
|
- 🔌 **[PLUGINS.md](documentation/PLUGINS.md)** - Plugin system and available plugins
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ from .utils import (
|
||||||
delete_folder,
|
delete_folder,
|
||||||
save_uploaded_image,
|
save_uploaded_image,
|
||||||
validate_path_security,
|
validate_path_security,
|
||||||
|
get_all_tags,
|
||||||
|
get_notes_by_tag,
|
||||||
)
|
)
|
||||||
from .plugins import PluginManager
|
from .plugins import PluginManager
|
||||||
from .themes import get_available_themes, get_theme_css
|
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"},
|
"body": {"oldPath": "Current folder path", "newPath": "New folder path"},
|
||||||
"response": "{ success, oldPath, newPath }"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/search",
|
"path": "/api/search",
|
||||||
|
|
@ -626,6 +641,47 @@ async def delete_folder_endpoint(folder_path: str):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@api_router.get("/notes")
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
"""List all notes with metadata"""
|
"""List all notes with metadata"""
|
||||||
|
|
|
||||||
236
backend/utils.py
236
backend/utils.py
|
|
@ -6,10 +6,15 @@ import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional, Tuple
|
||||||
from datetime import datetime
|
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:
|
def validate_path_security(notes_dir: str, path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Validate that a path is within the notes directory (security check).
|
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():
|
if not old_full_path.exists():
|
||||||
return False
|
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
|
# Create parent directory if needed
|
||||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
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():
|
if new_full_path.exists():
|
||||||
return False
|
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
|
# Create parent directory if needed
|
||||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
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}")
|
print(f"Path is not a directory: {full_path}")
|
||||||
return False
|
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
|
# Delete the folder and all its contents
|
||||||
shutil.rmtree(full_path)
|
shutil.rmtree(full_path)
|
||||||
print(f"Successfully deleted folder: {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)
|
relative_path = md_file.relative_to(notes_path)
|
||||||
stat = md_file.stat()
|
stat = md_file.stat()
|
||||||
|
|
||||||
|
# Get tags for this note (cached)
|
||||||
|
tags = get_tags_cached(md_file)
|
||||||
|
|
||||||
items.append({
|
items.append({
|
||||||
"name": md_file.stem,
|
"name": md_file.stem,
|
||||||
"path": str(relative_path.as_posix()),
|
"path": str(relative_path.as_posix()),
|
||||||
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
|
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
|
||||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"type": "note"
|
"type": "note",
|
||||||
|
"tags": tags
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get all images
|
# 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):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
return False
|
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()
|
full_path.unlink()
|
||||||
|
|
||||||
# Note: We don't automatically delete empty folders to preserve user's folder structure
|
# 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]:
|
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 = []
|
results = []
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
notes_path = Path(notes_dir)
|
notes_path = Path(notes_dir)
|
||||||
|
|
@ -246,7 +282,8 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
||||||
try:
|
try:
|
||||||
with open(md_file, 'r', encoding='utf-8') as f:
|
with open(md_file, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
|
# Only search in note content (not file name, folder name, or path)
|
||||||
if query_lower in content.lower():
|
if query_lower in content.lower():
|
||||||
# Find context around match
|
# Find context around match
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
|
|
@ -267,6 +304,7 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
||||||
results.append({
|
results.append({
|
||||||
"name": md_file.stem,
|
"name": md_file.stem,
|
||||||
"path": str(relative_path.as_posix()),
|
"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
|
"matches": matched_lines[:3] # Limit to 3 matches per file
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -419,3 +457,193 @@ def get_all_images(notes_dir: str) -> List[Dict]:
|
||||||
|
|
||||||
return images
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,39 @@
|
||||||
- **Event hooks** - React to note saves, deletes, searches
|
- **Event hooks** - React to note saves, deletes, searches
|
||||||
- **API access** - Full access to backend functionality
|
- **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
|
- **Real-time results** - As you type
|
||||||
- **Highlight matches** - See context in results
|
- **Highlight matches** - See context in results
|
||||||
- **In-note highlighting** - Search terms highlighted in open notes
|
- **In-note highlighting** - Search terms highlighted in open notes
|
||||||
- **Live highlighting** - Highlights update as you type or edit
|
- **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
|
## 🧮 Math & LaTeX Support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
276
frontend/app.js
276
frontend/app.js
|
|
@ -62,6 +62,12 @@ function noteApp() {
|
||||||
draggedFolder: null,
|
draggedFolder: null,
|
||||||
dragOverFolder: null, // Track which folder is being hovered during drag
|
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
|
// Scroll sync state
|
||||||
isScrolling: false,
|
isScrolling: false,
|
||||||
|
|
||||||
|
|
@ -256,6 +262,7 @@ function noteApp() {
|
||||||
this.loadSidebarWidth();
|
this.loadSidebarWidth();
|
||||||
this.loadEditorWidth();
|
this.loadEditorWidth();
|
||||||
this.loadViewMode();
|
this.loadViewMode();
|
||||||
|
this.loadTagsExpanded();
|
||||||
|
|
||||||
// Parse URL and load specific note if provided
|
// Parse URL and load specific note if provided
|
||||||
this.loadNoteFromURL();
|
this.loadNoteFromURL();
|
||||||
|
|
@ -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)
|
// Setup keyboard shortcuts (only once to prevent double triggers)
|
||||||
if (!window.__noteapp_shortcuts_initialized) {
|
if (!window.__noteapp_shortcuts_initialized) {
|
||||||
window.__noteapp_shortcuts_initialized = true;
|
window.__noteapp_shortcuts_initialized = true;
|
||||||
|
|
@ -536,11 +548,196 @@ function noteApp() {
|
||||||
this.notes = data.notes;
|
this.notes = data.notes;
|
||||||
this.allFolders = data.folders || [];
|
this.allFolders = data.folders || [];
|
||||||
this.buildFolderTree();
|
this.buildFolderTree();
|
||||||
|
await this.loadTags(); // Load tags after notes are loaded
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('load notes', 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
|
// Build folder tree structure
|
||||||
buildFolderTree() {
|
buildFolderTree() {
|
||||||
const tree = {};
|
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 => {
|
this.notes.forEach(note => {
|
||||||
if (!note.folder) {
|
if (!note.folder) {
|
||||||
// Root level note
|
// Root level note
|
||||||
|
|
@ -2072,6 +2269,17 @@ function noteApp() {
|
||||||
if (note) {
|
if (note) {
|
||||||
note.modified = new Date().toISOString();
|
note.modified = new Date().toISOString();
|
||||||
note.size = new Blob([this.noteContent]).size;
|
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
|
// Hide "saved" indicator
|
||||||
|
|
@ -2164,30 +2372,9 @@ function noteApp() {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Search notes
|
// Search notes
|
||||||
|
// Search notes by text (calls unified filter logic)
|
||||||
async searchNotes() {
|
async searchNotes() {
|
||||||
if (!this.searchQuery.trim()) {
|
await this.applyFilters();
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Trigger MathJax typesetting after DOM update
|
// Trigger MathJax typesetting after DOM update
|
||||||
|
|
@ -2305,6 +2492,26 @@ function noteApp() {
|
||||||
get renderedMarkdown() {
|
get renderedMarkdown() {
|
||||||
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
|
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
|
// Configure marked with syntax highlighting
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
|
|
@ -2322,7 +2529,7 @@ function noteApp() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse markdown
|
// 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
|
// Post-process: Add target="_blank" to external links and title attributes to images
|
||||||
// Parse as DOM to safely manipulate
|
// 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
|
// Start resizing sidebar
|
||||||
startResize(event) {
|
startResize(event) {
|
||||||
this.isResizing = true;
|
this.isResizing = true;
|
||||||
|
|
|
||||||
|
|
@ -753,7 +753,6 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p>
|
<p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p>
|
||||||
|
|
||||||
<!-- Logout Button (only show if auth is enabled) -->
|
<!-- 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)">
|
<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"
|
<a x-show="authEnabled"
|
||||||
|
|
@ -829,6 +828,74 @@
|
||||||
</div>
|
</div>
|
||||||
</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 Filters (<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 -->
|
<!-- New Item Dropdown -->
|
||||||
<div class="p-3 border-b relative" style="border-color: var(--border-primary);">
|
<div class="p-3 border-b relative" style="border-color: var(--border-primary);">
|
||||||
<button
|
<button
|
||||||
|
|
@ -886,7 +953,10 @@
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
<template x-if="searchResults.length > 0">
|
<template x-if="searchResults.length > 0">
|
||||||
<div class="p-2">
|
<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">
|
<template x-for="note in searchResults" :key="note.path">
|
||||||
<div
|
<div
|
||||||
@click="loadNote(note.path, true, searchQuery)"
|
@click="loadNote(note.path, true, searchQuery)"
|
||||||
|
|
@ -902,7 +972,51 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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="p-2">
|
||||||
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
|
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue