diff --git a/README.md b/README.md index b3a3e0e..ea09441 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/main.py b/backend/main.py index 35a99e4..06694c0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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""" diff --git a/backend/utils.py b/backend/utils.py index 365cf53..8459805 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -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 + diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index be629fa..7509ef7 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -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 diff --git a/documentation/TAGS.md b/documentation/TAGS.md new file mode 100644 index 0000000..d3e11f5 --- /dev/null +++ b/documentation/TAGS.md @@ -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 +``` + diff --git a/frontend/app.js b/frontend/app.js index 56d0a22..afb5102 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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,6 +262,7 @@ function noteApp() { this.loadSidebarWidth(); this.loadEditorWidth(); this.loadViewMode(); + this.loadTagsExpanded(); // Parse URL and load specific note if provided 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) 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 '
Nothing to preview yet...
'; + // 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; diff --git a/frontend/index.html b/frontend/index.html index 0db64b9..7a0af9d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -753,7 +753,6 @@ - + +