added tagging system

This commit is contained in:
Gamosoft 2025-11-24 17:11:22 +01:00
parent d6595831e9
commit b56c78587e
7 changed files with 796 additions and 35 deletions

View File

@ -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

View File

@ -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"""

View File

@ -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

View File

@ -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

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, 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;

View File

@ -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">