diff --git a/README.md b/README.md index 1084e60..de341af 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ If this project has been useful to you, consider supporting its development, it - 📄 **HTML Export** - Share notes as standalone HTML files - 🕸️ **Graph View** - Interactive visualization of connected notes - ⭐ **Favorites** - Star your most-used notes for instant access +- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC ## 🚀 Quick Start diff --git a/backend/utils.py b/backend/utils.py index f978f1c..7f093ac 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict: def sanitize_filename(filename: str) -> str: """ - Sanitize a filename by removing/replacing invalid characters. - Keeps only alphanumeric chars, dots, dashes, and underscores. + Sanitize a filename by removing/replacing dangerous filesystem characters. + Supports Unicode characters (international text) while blocking: + - Windows forbidden: \\ / : * ? " < > | + - Control characters (0x00-0x1f) + + Note: This is a safety net - the frontend validates before sending. """ + if not filename: + return filename + # Get the extension first parts = filename.rsplit('.', 1) name = parts[0] ext = parts[1] if len(parts) > 1 else '' - # Remove/replace invalid characters - name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + # Remove dangerous characters (replace with underscore) + # Blocklist approach: only remove what's truly dangerous + # Pattern: backslash, forward slash, colon, asterisk, question mark, quotes, angle brackets, pipe, control chars + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', name) + + # Collapse multiple underscores + name = re.sub(r'_+', '_', name) + + # Strip leading/trailing underscores and spaces + name = name.strip('_ ') + + # Ensure we have something left + if not name: + name = 'unnamed' # Rejoin with extension return f"{name}.{ext}" if ext else name diff --git a/config.yaml b/config.yaml index fdaf2a3..713c704 100644 --- a/config.yaml +++ b/config.yaml @@ -27,7 +27,7 @@ search: authentication: # Authentication settings # Set enabled to true to require login - enabled: true + enabled: false # ⚠️ SECURITY WARNING: Change these values before exposing to the internet! # Default values below are for LOCAL TESTING ONLY diff --git a/docs/index.html b/docs/index.html index e57b741..05472dd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -823,7 +823,7 @@
🔗

Wikilinks

-

Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.

+

Link notes with [[double brackets]] Obsidian-style. Jump to sections with [[note#heading]] anchors.

@@ -850,6 +850,12 @@

Star your most-used notes for instant access from the sidebar.

+
+
📑
+

Outline Panel

+

Navigate headings with a click-to-jump table of contents. Link to sections with anchors.

+
+
🔌

Extensible

diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 62025b4..2f1a20a 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -37,12 +37,31 @@ ### Internal Links - **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking - **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text +- **Section anchors** - `[[Note Name#heading]]` to link directly to a heading +- **Same-page anchors** - `[[#heading]]` to link within the current note - **Broken link detection** - Non-existent note links shown dimmed -- **Markdown links** - `[Note Name](note.md)` standard syntax also supported +- **Markdown links** - `[text](note.md)` standard syntax also supported +- **Markdown section links** - `[text](note.md#heading)` for heading anchors - **Drag to link** - Drag notes or images into the editor to insert links - **Click to navigate** - Jump between notes seamlessly - **External links** - Open in new tabs automatically +### Outline Panel +- **Table of Contents** - View all headings (H1-H6) in sidebar +- **Click to navigate** - Jump to any heading in edit or preview mode +- **Real-time updates** - Outline updates as you type +- **Hierarchical view** - Indentation shows heading structure +- **Heading count badge** - Quick indicator of document structure + +### Section Link Syntax +To link to a heading, convert the heading text to a slug: **lowercase, spaces → dashes, remove special chars**. + +| Heading | Slug | Link Example | +|---------|------|--------------| +| `## Getting Started` | `getting-started` | `[[note#getting-started]]` | +| `### API Reference` | `api-reference` | `[API](note#api-reference)` | +| `## What's New?` | `whats-new` | `[[#whats-new]]` (same page) | + ### Direct URLs - **Deep linking** - Open specific notes via URL (e.g., `/folder/note`) - **Search highlighting** - Add `?search=term` to highlight specific content diff --git a/frontend/app.js b/frontend/app.js index facaa34..73eecec 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -32,12 +32,103 @@ const ErrorHandler = { } }; +/** + * Centralized filename validation + * Supports Unicode characters (international text) but blocks dangerous filesystem characters. + * Does NOT silently modify filenames - validates and returns status. + */ +const FilenameValidator = { + // Characters that are forbidden in filenames across Windows/macOS/Linux + // Windows: \ / : * ? " < > | + // macOS: / : + // Linux: / \0 + // Common set to block (including control characters) + FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/, + + // For display purposes - human readable list + FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |', + + /** + * Validate a filename (single segment, no path separators) + * @param {string} name - The filename to validate + * @returns {{ valid: boolean, error?: string, sanitized?: string }} + */ + validateFilename(name) { + if (!name || typeof name !== 'string') { + return { valid: false, error: 'empty' }; + } + + const trimmed = name.trim(); + if (!trimmed) { + return { valid: false, error: 'empty' }; + } + + // Check for forbidden characters + if (this.FORBIDDEN_CHARS.test(trimmed)) { + return { + valid: false, + error: 'forbidden_chars', + forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY + }; + } + + // Check for reserved Windows names (case-insensitive) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + if (reservedNames.test(trimmed)) { + return { valid: false, error: 'reserved_name' }; + } + + // Check for names starting/ending with dots or spaces (problematic on some systems) + if (trimmed.startsWith('.') && trimmed.length === 1) { + return { valid: false, error: 'invalid_dot' }; + } + if (trimmed.endsWith('.') || trimmed.endsWith(' ')) { + return { valid: false, error: 'trailing_dot_space' }; + } + + return { valid: true, sanitized: trimmed }; + }, + + /** + * Validate a path (may contain forward slashes for folder separators) + * @param {string} path - The path to validate + * @returns {{ valid: boolean, error?: string, sanitized?: string }} + */ + validatePath(path) { + if (!path || typeof path !== 'string') { + return { valid: false, error: 'empty' }; + } + + const trimmed = path.trim(); + if (!trimmed) { + return { valid: false, error: 'empty' }; + } + + // Split by forward slash and validate each segment + const segments = trimmed.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return { valid: false, error: 'empty' }; + } + + for (const segment of segments) { + const result = this.validateFilename(segment); + if (!result.valid) { + return result; + } + } + + // Rebuild path without empty segments + return { valid: true, sanitized: segments.join('/') }; + } +}; + function noteApp() { return { // App state appName: 'NoteDiscovery', appVersion: '0.0.0', authEnabled: false, + demoMode: false, notes: [], currentNote: '', currentNoteName: '', @@ -111,6 +202,9 @@ function noteApp() { tagsExpanded: false, tagReloadTimeout: null, // For debouncing tag reloads + // Outline (TOC) state + outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...] + // Scroll sync state isScrolling: false, @@ -382,6 +476,7 @@ function noteApp() { this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; + this.outline = []; // Restore homepage folder state if it was saved if (e.state && e.state.homepageFolder !== undefined) { @@ -569,6 +664,7 @@ function noteApp() { this.appName = config.name; this.appVersion = config.version || '0.0.0'; this.authEnabled = config.authentication?.enabled || false; + this.demoMode = config.demoMode || false; } catch (error) { console.error('Failed to load config:', error); } @@ -802,6 +898,35 @@ function noteApp() { return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); }, + /** + * Get localized error message from FilenameValidator result + * @param {object} validation - The validation result from FilenameValidator + * @param {string} type - 'note' or 'folder' + * @returns {string} Localized error message + */ + getValidationErrorMessage(validation, type = 'note') { + switch (validation.error) { + case 'empty': + return type === 'note' + ? this.t('notes.empty_name') + : this.t('folders.invalid_name'); + case 'forbidden_chars': + return this.t('validation.forbidden_chars', { + chars: validation.forbiddenChars + }); + case 'reserved_name': + return this.t('validation.reserved_name'); + case 'invalid_dot': + return this.t('validation.invalid_dot'); + case 'trailing_dot_space': + return this.t('validation.trailing_dot_space'); + default: + return type === 'note' + ? this.t('notes.invalid_name') + : this.t('folders.invalid_name'); + } + }, + // Load available locales from backend async loadAvailableLocales() { try { @@ -968,8 +1093,15 @@ function noteApp() { } try { + // Validate the note name + const validation = FilenameValidator.validateFilename(this.newTemplateNoteName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); + return; + } + // Determine the note path based on dropdown context - let notePath = this.newTemplateNoteName.trim(); + let notePath = validation.sanitized; if (!notePath.endsWith('.md')) { notePath += '.md'; } @@ -990,7 +1122,7 @@ function noteApp() { // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() })); + alert(this.t('notes.already_exists', { name: validation.sanitized })); return; } @@ -1034,6 +1166,121 @@ function noteApp() { this.applyFilters(); }, + // ======================================================================== + // Outline (TOC) Methods + // ======================================================================== + + // Extract headings from markdown content for the outline + extractOutline(content) { + if (!content) { + this.outline = []; + return; + } + + const headings = []; + const lines = content.split('\n'); + const slugCounts = {}; // Track duplicate slugs + + // Skip frontmatter + let inFrontmatter = false; + let frontmatterEnded = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Handle frontmatter + if (i === 0 && line.trim() === '---') { + inFrontmatter = true; + continue; + } + if (inFrontmatter) { + if (line.trim() === '---') { + inFrontmatter = false; + frontmatterEnded = true; + } + continue; + } + + // Match heading lines (# to ######) + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (match) { + const level = match[1].length; + const text = match[2].trim(); + + // Generate slug (GitHub-style) + let slug = text + .toLowerCase() + .replace(/[^\w\s-]/g, '') // Remove special chars + .replace(/\s+/g, '-') // Spaces to dashes + .replace(/-+/g, '-'); // Multiple dashes to single + + // Handle duplicate slugs + if (slugCounts[slug] !== undefined) { + slugCounts[slug]++; + slug = `${slug}-${slugCounts[slug]}`; + } else { + slugCounts[slug] = 0; + } + + headings.push({ + level, + text, + slug, + line: i + 1 // 1-indexed line number + }); + } + } + + this.outline = headings; + }, + + // Scroll to a heading in the editor or preview + scrollToHeading(heading) { + if (this.viewMode === 'preview' || this.viewMode === 'split') { + // In preview/split mode, scroll the preview pane + const preview = document.querySelector('.markdown-preview'); + if (preview) { + // Find the heading element by text content (more reliable than ID) + const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6'); + for (const el of headingElements) { + if (el.textContent.trim() === heading.text) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Add a brief highlight effect + el.style.transition = 'background-color 0.3s'; + el.style.backgroundColor = 'var(--accent-light)'; + setTimeout(() => { + el.style.backgroundColor = ''; + }, 1000); + return; + } + } + } + } + + if (this.viewMode === 'edit' || this.viewMode === 'split') { + // In edit/split mode, scroll the editor to the line + const textarea = document.querySelector('.editor-textarea'); + if (textarea && heading.line) { + const lines = textarea.value.split('\n'); + let charPos = 0; + + // Calculate character position of the heading line + for (let i = 0; i < heading.line - 1 && i < lines.length; i++) { + charPos += lines[i].length + 1; // +1 for newline + } + + // Set cursor position and scroll + textarea.focus(); + textarea.setSelectionRange(charPos, charPos); + + // Calculate scroll position (approximate) + const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24; + const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3; + textarea.scrollTop = Math.max(0, scrollTop); + } + } + }, + // Unified filtering logic combining tags and text search async applyFilters() { const hasTextSearch = this.searchQuery.trim().length > 0; @@ -1945,42 +2192,79 @@ function noteApp() { // Prevent default navigation for internal links event.preventDefault(); - // Remove any anchor from the href (e.g., "note.md#section" -> "note.md") - // Also decode URL encoding (e.g., "note%203.md" -> "note 3.md") - const notePath = decodeURIComponent(href.split('#')[0]); + // Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section") + const decodedHref = decodeURIComponent(href); + const hashIndex = decodedHref.indexOf('#'); + const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref; + const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null; - // Skip if it's just an anchor link + // If it's just an anchor link (#heading), scroll within current note + if (!notePath && anchor) { + this.scrollToAnchor(anchor); + return; + } + + // Skip if no path if (!notePath) return; // Find the note by path (try exact match first, then with .md extension) - const note = this.notes.find(n => + let targetNote = this.notes.find(n => n.path === notePath || n.path === notePath + '.md' ); - if (note) { - this.loadNote(note.path); - } else { + + if (!targetNote) { // Try to find by name (in case link uses just the note name without path) - const noteByName = this.notes.find(n => + targetNote = this.notes.find(n => n.name === notePath || n.name === notePath + '.md' || - // Also match by filename at end of path (case-insensitive) n.name.toLowerCase() === notePath.toLowerCase() || n.name.toLowerCase() === (notePath + '.md').toLowerCase() ); - if (noteByName) { - this.loadNote(noteByName.path); - } else { - // Last resort: case-insensitive path matching - const noteByPathCI = this.notes.find(n => - n.path.toLowerCase() === notePath.toLowerCase() || - n.path.toLowerCase() === (notePath + '.md').toLowerCase() - ); - if (noteByPathCI) { - this.loadNote(noteByPathCI.path); - } else { - alert(this.t('notes.not_found', { path: notePath })); + } + + if (!targetNote) { + // Last resort: case-insensitive path matching + targetNote = this.notes.find(n => + n.path.toLowerCase() === notePath.toLowerCase() || + n.path.toLowerCase() === (notePath + '.md').toLowerCase() + ); + } + + if (targetNote) { + // Load the note, then scroll to anchor if present + this.loadNote(targetNote.path).then(() => { + if (anchor) { + // Small delay to ensure content is rendered + setTimeout(() => this.scrollToAnchor(anchor), 100); } + }); + } else { + alert(this.t('notes.not_found', { path: notePath })); + } + }, + + // Scroll to an anchor (heading) by slug - reuses outline data + scrollToAnchor(anchor) { + // Normalize the anchor (GitHub-style slug) + const targetSlug = anchor + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + + // Find matching heading in outline + const heading = this.outline.find(h => h.slug === targetSlug); + + if (heading) { + this.scrollToHeading(heading); + } else { + // Fallback: try to find heading by exact text match + const headingByText = this.outline.find(h => + h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase() + ); + if (headingByText) { + this.scrollToHeading(headingByText); } } }, @@ -2182,6 +2466,9 @@ function noteApp() { this.currentImage = ''; // Clear image viewer when loading a note this.lastSaved = false; + // Extract outline for TOC panel + this.extractOutline(data.content); + // Initialize undo/redo history for this note (with cursor at start) this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.redoHistory = []; @@ -2548,23 +2835,29 @@ function noteApp() { const noteName = prompt(promptText); if (!noteName) return; - const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); - if (!sanitizedName) { - alert(this.t('notes.invalid_name')); + // Validate the name/path (may contain / for paths when no target folder) + const validation = targetFolder + ? FilenameValidator.validateFilename(noteName) + : FilenameValidator.validatePath(noteName); + + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); return; } + const validatedName = validation.sanitized; + let notePath; if (targetFolder) { - notePath = `${targetFolder}/${sanitizedName}.md`; + notePath = `${targetFolder}/${validatedName}.md`; } else { - notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; + notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`; } // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(this.t('notes.already_exists', { name: sanitizedName })); + alert(this.t('notes.already_exists', { name: validatedName })); return; } @@ -2609,18 +2902,23 @@ function noteApp() { const folderName = prompt(promptText); if (!folderName) return; - const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); - if (!sanitizedName) { - alert(this.t('folders.invalid_name')); + // Validate the name/path (may contain / for paths when no target folder) + const validation = targetFolder + ? FilenameValidator.validateFilename(folderName) + : FilenameValidator.validatePath(folderName); + + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'folder')); return; } - const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName; + const validatedName = validation.sanitized; + const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName; // Check if folder already exists const existingFolder = this.allFolders.find(folder => folder === folderPath); if (existingFolder) { - alert(this.t('folders.already_exists', { name: sanitizedName })); + alert(this.t('folders.already_exists', { name: validatedName })); return; } @@ -2653,15 +2951,18 @@ function noteApp() { const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName); if (!newName || newName === currentName) return; - const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); - if (!sanitizedName) { - alert(this.t('folders.invalid_name')); + // Validate the new name (single segment, no path separators) + const validation = FilenameValidator.validateFilename(newName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'folder')); return; } + const validatedName = validation.sanitized; + // Calculate new path const pathParts = folderPath.split('/'); - pathParts[pathParts.length - 1] = sanitizedName; + pathParts[pathParts.length - 1] = validatedName; const newPath = pathParts.join('/'); try { @@ -2772,6 +3073,9 @@ function noteApp() { // Parse metadata in real-time this.parseMetadata(); + // Update outline (TOC) in real-time + this.extractOutline(this.noteContent); + this.saveTimeout = setTimeout(() => { this.saveNote(); }, CONFIG.AUTOSAVE_DELAY); @@ -3025,15 +3329,25 @@ function noteApp() { return; } + // Validate the new name (single segment, no path separators) + const validation = FilenameValidator.validateFilename(newName); + if (!validation.valid) { + alert(this.getValidationErrorMessage(validation, 'note')); + // Reset the name in the UI + this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); + return; + } + + const validatedName = validation.sanitized; const folder = oldPath.split('/').slice(0, -1).join('/'); - const newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`; + const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`; if (oldPath === newPath) return; // Check if a note with the new name already exists const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); if (existingNote) { - alert(this.t('notes.already_exists', { name: newName })); + alert(this.t('notes.already_exists', { name: validatedName })); // Reset the name in the UI this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); return; @@ -4489,6 +4803,7 @@ function noteApp() { this.currentNoteName = ''; this.noteContent = ''; this.currentImage = ''; + this.outline = []; // Invalidate cache to force recalculation this._homepageCache = { @@ -4509,6 +4824,7 @@ function noteApp() { this.currentNoteName = ''; this.noteContent = ''; this.currentImage = ''; + this.outline = []; this.mobileSidebarOpen = false; // Invalidate cache to force recalculation diff --git a/frontend/index.html b/frontend/index.html index 03b5a91..cd8dd35 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1157,7 +1157,7 @@ :class="{'active': activePanel === 'files'}" @click="activePanel = 'files'" :title="t('sidebar.files')" - aria-label="Files panel" + :aria-label="t('sidebar.files')" > @@ -1170,7 +1170,7 @@ :class="{'active': activePanel === 'search'}" @click="activePanel = 'search'" :title="t('sidebar.search')" - aria-label="Search panel" + :aria-label="t('sidebar.search')" > @@ -1191,7 +1191,7 @@ :class="{'active': activePanel === 'tags'}" @click="activePanel = 'tags'" :title="t('tags.title')" - aria-label="Tags panel" + :aria-label="t('tags.title')" > @@ -1206,6 +1206,27 @@ > + + + +
@@ -1214,7 +1235,7 @@ :class="{'active': showGraph}" @click="showGraph = true; initGraph()" :title="t('graph.title')" - aria-label="Graph view" + :aria-label="t('graph.title')" > @@ -1227,7 +1248,7 @@ :class="{'active': activePanel === 'settings'}" @click="activePanel = 'settings'" :title="t('sidebar.settings')" - aria-label="Settings panel" + :aria-label="t('sidebar.settings')" > @@ -1578,6 +1599,51 @@
+ +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+ +