diff --git a/README.md b/README.md index 8c33fae..b3a3e0e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > Your Self-Hosted Knowledge Base +๐ŸŒ **[Visit the official website](https://www.notediscovery.com)** + ## What is NoteDiscovery? NoteDiscovery is a **lightweight, self-hosted note-taking application** that puts you in complete control of your knowledge base. Write, organize, and discover your notes with a beautiful, modern interfaceโ€”all running on your own server. diff --git a/backend/main.py b/backend/main.py index 4b0e2c1..35a99e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,7 +23,6 @@ from .utils import ( save_note, delete_note, search_notes, - parse_wiki_links, create_note_metadata, ensure_directories, create_folder, @@ -336,8 +335,8 @@ async def api_documentation(): { "method": "GET", "path": "/api/graph", - "description": "Get graph data for note visualization (wiki links)", - "response": "{ nodes: [{ id, label }], edges: [{ from, to }] }" + "description": "Get graph data for note visualization", + "response": "{ nodes: [{ id, label }], edges: [] }" }, { "method": "GET", @@ -651,13 +650,9 @@ async def get_note(note_path: str): if transformed_content is not None: content = transformed_content - # Parse wiki links - links = parse_wiki_links(content) - return { "path": note_path, "content": content, - "links": links, "metadata": create_note_metadata(config['storage']['notes_dir'], note_path) } except HTTPException: @@ -745,28 +740,22 @@ async def search(q: str): @api_router.get("/graph") async def get_graph(): - """Get graph data for visualization""" + """Get graph data for note visualization (currently only returns nodes, link detection not implemented)""" try: notes = get_all_notes(config['storage']['notes_dir']) nodes = [] edges = [] - # Build graph structure + # Build graph structure - only nodes for now for note in notes: - nodes.append({ - "id": note['path'], - "label": note['name'] - }) - - # Get links from this note - content = get_note_content(config['storage']['notes_dir'], note['path']) - if content: - links = parse_wiki_links(content) - for link in links: - edges.append({ - "from": note['path'], - "to": link - }) + if note.get('type') == 'note': # Only include actual notes + nodes.append({ + "id": note['path'], + "label": note['name'] + }) + + # Note: Link detection between notes could be implemented here using markdown link parsing + # For now, returning empty edges return {"nodes": nodes, "edges": edges} except Exception as e: diff --git a/backend/utils.py b/backend/utils.py index e953e93..365cf53 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -236,13 +236,6 @@ def delete_note(notes_dir: str, note_path: str) -> bool: return True -def parse_wiki_links(content: str) -> List[str]: - """Extract wiki-style links [[link]] from markdown content""" - pattern = r'\[\[([^\]]+)\]\]' - matches = re.findall(pattern, content) - return matches - - def search_notes(notes_dir: str, query: str) -> List[Dict]: """Simple full-text search through all notes""" results = [] diff --git a/docs/index.html b/docs/index.html index 2527a08..e9f3244 100644 --- a/docs/index.html +++ b/docs/index.html @@ -641,8 +641,8 @@
๐Ÿ”—
- Wiki Links - Connect notes with internal links + Internal Links + Connect notes with markdown links
@@ -725,7 +725,7 @@ "url": "https://www.notediscovery.com", "applicationCategory": "ProductivityApplication", "operatingSystem": "Linux, Windows, macOS", - "featureList": "Markdown editor, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal wiki links, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", + "featureList": "Markdown editor, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", "offers": { "@type": "Offer", "price": "0", diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 994fe06..be629fa 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -28,7 +28,7 @@ ## ๐Ÿ”— Linking & Discovery ### Internal Links -- **Wiki-style links** - `[[Note Name]]` syntax +- **Markdown links** - `[Note Name](note.md)` syntax - **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 @@ -139,6 +139,8 @@ graph TD ## โšก Keyboard Shortcuts +### General + | Windows/Linux | Mac | Action | |---------------|-----|--------| | `Ctrl+S` | `Cmd+S` | Save note | @@ -149,6 +151,14 @@ graph TD | `F3` | `F3` | Next search match | | `Shift+F3` | `Shift+F3` | Previous search match | +### Markdown Formatting + +| Windows/Linux | Mac | Action | Result | +|---------------|-----|--------|--------| +| `Ctrl+B` | `Cmd+B` | Bold | `**text**` | +| `Ctrl+I` | `Cmd+I` | Italic | `*text*` | +| `Ctrl+K` | `Cmd+K` | Insert link | `[text](url)` | + ## ๐Ÿš€ Performance - **Instant loading** - No lag, no loading spinners diff --git a/documentation/PLUGIN_NOTE_STATISTICS.md b/documentation/PLUGIN_NOTE_STATISTICS.md index 1633012..2494dc5 100644 --- a/documentation/PLUGIN_NOTE_STATISTICS.md +++ b/documentation/PLUGIN_NOTE_STATISTICS.md @@ -55,7 +55,7 @@ curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \ ### ๐Ÿ”— Links & References - **Total Links** - All `[text](url)` links -- **Internal Links** - `[[WikiLinks]]` to other notes +- **Internal Links** - Links to other notes (`.md` files) - **External Links** - HTTP/HTTPS URLs - **Images** - `![alt](image.png)` count @@ -191,7 +191,7 @@ Find orphaned notes or track reference density. **Words:** Split on whitespace, excluding code blocks **Reading Time:** `words / 200 minutes` -**Links:** Regex match `[text](url)` and `[[WikiLink]]` +**Links:** Regex match `[text](url)` markdown links **Tasks:** Match `- [ ]` and `- [x]` **Code Blocks:** Match ` ```language ` ` fences **Headings:** Match `#`, `##`, `###` at line start diff --git a/frontend/app.js b/frontend/app.js index 7041c09..56d0a22 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -94,89 +94,112 @@ function noteApp() { // Dropdown state showNewDropdown: false, - // Homepage folder navigation + // Homepage state selectedHomepageFolder: '', + _homepageCache: { + folderPath: null, + notes: null, + folders: null, + breadcrumb: null + }, - // Computed-like helpers for homepage (use methods for safer evaluation) + // Homepage constants + HOMEPAGE_MAX_NOTES: 50, + + // Computed-like helpers for homepage (cached for performance) homepageNotes() { + // Return cached result if folder hasn't changed + if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) { + return this._homepageCache.notes; + } + if (!this.folderTree || typeof this.folderTree !== 'object') { return []; } const folderNode = this.getFolderNode(this.selectedHomepageFolder || ''); - if (!folderNode || !Array.isArray(folderNode.notes)) { - return []; - } + const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : []; - return folderNode.notes; - }, - - // Helper: Format file size nicely - formatSize(bytes) { - if (bytes === 0 || !bytes) return '0 B'; - - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + // Cache the result + this._homepageCache.notes = result; + this._homepageCache.folderPath = this.selectedHomepageFolder; + + return result; }, homepageFolders() { + // Return cached result if folder hasn't changed + if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) { + return this._homepageCache.folders; + } + if (!this.folderTree || typeof this.folderTree !== 'object') { return []; } + // Get child folders let childFolders = []; if (!this.selectedHomepageFolder) { + // Root level: all top-level folders childFolders = Object.entries(this.folderTree) .filter(([key]) => key !== '__root__') .map(([, folder]) => folder); } else { + // Inside a folder: get its children const parentFolder = this.getFolderNode(this.selectedHomepageFolder); - if (!parentFolder || !parentFolder.children) { - return []; + if (parentFolder && parentFolder.children) { + childFolders = Object.values(parentFolder.children); } - childFolders = Object.values(parentFolder.children); } - if (!Array.isArray(childFolders) || childFolders.length === 0) { - return []; - } - - return childFolders + // Map to simplified structure (note count already cached in folder node) + const result = childFolders .map(folder => ({ name: folder.name, path: folder.path, - noteCount: this.calculateFolderNoteCount(folder) + noteCount: folder.noteCount || 0 // Use pre-calculated count })) .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + + // Cache the result + this._homepageCache.folders = result; + this._homepageCache.folderPath = this.selectedHomepageFolder; + + return result; }, homepageBreadcrumb() { - // Always return at least Home breadcrumb - // Safety check - ensure we always return an array - try { - const breadcrumb = [{ name: 'Home', path: '' }]; - - if (!this.selectedHomepageFolder) { - return breadcrumb; - } - - const parts = (this.selectedHomepageFolder || '').split('/').filter(part => part.trim() !== ''); - + // Return cached result if folder hasn't changed + if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) { + return this._homepageCache.breadcrumb; + } + + const breadcrumb = [{ name: 'Home', path: '' }]; + + if (this.selectedHomepageFolder) { + const parts = this.selectedHomepageFolder.split('/').filter(Boolean); let currentPath = ''; + parts.forEach(part => { - currentPath = currentPath ? currentPath + '/' + part : part; + currentPath = currentPath ? `${currentPath}/${part}` : part; breadcrumb.push({ name: part, path: currentPath }); }); - - return breadcrumb; - } catch (error) { - // Fallback to just Home if anything goes wrong - return [{ name: 'Home', path: '' }]; } + + // Cache the result + this._homepageCache.breadcrumb = breadcrumb; + this._homepageCache.folderPath = this.selectedHomepageFolder; + + return breadcrumb; + }, + + // Helper: Format file size nicely + formatSize(bytes) { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, getFolderNode(folderPath = '') { @@ -185,10 +208,7 @@ function noteApp() { } if (!folderPath) { - if (this.folderTree['__root__']) { - return this.folderTree['__root__']; - } - return { name: '', path: '', children: {}, notes: [] }; + return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 }; } const parts = folderPath.split('/').filter(Boolean); @@ -206,22 +226,6 @@ function noteApp() { return node; }, - calculateFolderNoteCount(folderNode) { - if (!folderNode || typeof folderNode !== 'object') { - return 0; - } - - const directNotes = Array.isArray(folderNode.notes) ? folderNode.notes.length : 0; - if (!folderNode.children || Object.keys(folderNode.children).length === 0) { - return directNotes; - } - - return Object.values(folderNode.children).reduce( - (total, child) => total + this.calculateFolderNoteCount(child), - directNotes - ); - }, - // Check if app is empty (no notes and no folders) get isAppEmpty() { const notesArray = Array.isArray(this.notes) ? this.notes : []; @@ -291,6 +295,14 @@ function noteApp() { this.selectedHomepageFolder = ''; } + // Invalidate cache to force recalculation + this._homepageCache = { + folderPath: null, + notes: null, + folders: null, + breadcrumb: null + }; + // Clear search this.searchQuery = ''; this.searchResults = []; @@ -373,6 +385,28 @@ function noteApp() { e.preventDefault(); this.previousMatch(); } + + // Only apply markdown shortcuts when editor is focused and a note is open + const isEditorFocused = document.activeElement?.id === 'note-editor'; + if (isEditorFocused && this.currentNote) { + // Ctrl/Cmd + B for bold + if ((e.ctrlKey || e.metaKey) && e.key === 'b') { + e.preventDefault(); + this.wrapSelection('**', '**', 'bold text'); + } + + // Ctrl/Cmd + I for italic + if ((e.ctrlKey || e.metaKey) && e.key === 'i') { + e.preventDefault(); + this.wrapSelection('*', '*', 'italic text'); + } + + // Ctrl/Cmd + K for link + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + this.insertLink(); + } + } }); } @@ -590,8 +624,41 @@ function noteApp() { } }); - // Force Alpine reactivity by creating a new object reference - this.folderTree = { ...tree }; + // Calculate and cache note counts recursively (for performance) + const calculateNoteCounts = (folderNode) => { + const directNotes = folderNode.notes ? folderNode.notes.length : 0; + + if (!folderNode.children || Object.keys(folderNode.children).length === 0) { + folderNode.noteCount = directNotes; + return directNotes; + } + + const childNotesCount = Object.values(folderNode.children).reduce( + (total, child) => total + calculateNoteCounts(child), + 0 + ); + + folderNode.noteCount = directNotes + childNotesCount; + return folderNode.noteCount; + }; + + // Calculate note counts for all folders + Object.values(tree).forEach(folder => { + if (folder.path !== undefined || folder === tree['__root__']) { + calculateNoteCounts(folder); + } + }); + + // Invalidate homepage cache when tree is rebuilt + this._homepageCache = { + folderPath: null, + notes: null, + folders: null, + breadcrumb: null + }; + + // Assign new tree (Alpine will detect the change) + this.folderTree = tree; }, // Render folder recursively (helper for deep nesting) @@ -756,8 +823,6 @@ function noteApp() { } // Force Alpine reactivity by creating new Set reference this.expandedFolders = new Set(this.expandedFolders); - // Also trigger folderTree reactivity to re-render x-html - this.folderTree = { ...this.folderTree }; }, // Check if folder is expanded @@ -767,25 +832,22 @@ function noteApp() { // Expand all folders expandAllFolders() { - // Add all folder paths to expandedFolders this.allFolders.forEach(folder => { this.expandedFolders.add(folder); }); - // Force Alpine reactivity by creating new object reference (no rebuild needed) - this.folderTree = { ...this.folderTree }; + // Force Alpine reactivity + this.expandedFolders = new Set(this.expandedFolders); }, // Collapse all folders collapseAllFolders() { this.expandedFolders.clear(); - // Force Alpine reactivity by creating new object reference (no rebuild needed) - this.folderTree = { ...this.folderTree }; + // Force Alpine reactivity + this.expandedFolders = new Set(this.expandedFolders); }, // Expand folder tree to show a specific note expandFolderForNote(notePath) { - // Extract folder path from note path - // e.g., "folder1/folder2/note.md" -> ["folder1", "folder1/folder2"] const parts = notePath.split('/'); // If note is in root, no folders to expand @@ -801,13 +863,8 @@ function noteApp() { this.expandedFolders.add(currentPath); }); - // Force Alpine reactivity by creating new object reference - // This ensures the UI updates to show the expanded folders - this.folderTree = { ...this.folderTree }; - - // Also force a re-evaluation by modifying the Set (create new Set) - const oldFolders = this.expandedFolders; - this.expandedFolders = new Set(oldFolders); + // Force Alpine reactivity + this.expandedFolders = new Set(this.expandedFolders); }, // Scroll note into view in the sidebar navigation @@ -1932,6 +1989,68 @@ function noteApp() { }); }, + // Markdown formatting helpers + wrapSelection(before, after, placeholder) { + const editor = document.getElementById('note-editor'); + if (!editor) return; + + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selectedText = this.noteContent.substring(start, end); + const textToWrap = selectedText || placeholder; + + // Build the new text + const newText = before + textToWrap + after; + + // Update content + this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end); + + // Set cursor position (select the wrapped text or placeholder) + this.$nextTick(() => { + if (selectedText) { + // If text was selected, keep it selected (inside the wrapper) + editor.setSelectionRange(start + before.length, start + before.length + selectedText.length); + } else { + // If no text selected, select the placeholder + editor.setSelectionRange(start + before.length, start + before.length + placeholder.length); + } + editor.focus(); + }); + + // Trigger autosave + this.autoSave(); + }, + + insertLink() { + const editor = document.getElementById('note-editor'); + if (!editor) return; + + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selectedText = this.noteContent.substring(start, end); + + // If text is selected, use it as link text; otherwise use placeholder + const linkText = selectedText || 'link text'; + const linkUrl = 'url'; + + // Build the markdown link + const newText = `[${linkText}](${linkUrl})`; + + // Update content + this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end); + + // Set cursor position to select the URL part for easy editing + this.$nextTick(() => { + const urlStart = start + linkText.length + 3; // After "[linkText](" + const urlEnd = urlStart + linkUrl.length; + editor.setSelectionRange(urlStart, urlEnd); + editor.focus(); + }); + + // Trigger autosave + this.autoSave(); + }, + // Save current note async saveNote() { if (!this.currentNote) return; @@ -2202,13 +2321,8 @@ function noteApp() { } }); - // Convert wiki-style links [[link]] to HTML links - let content = this.noteContent.replace(/\[\[([^\]]+)\]\]/g, (match, linkText) => { - return `[[${linkText}]]`; - }); - // Parse markdown - let html = marked.parse(content); + let html = marked.parse(this.noteContent); // Post-process: Add target="_blank" to external links and title attributes to images // Parse as DOM to safely manipulate @@ -2955,12 +3069,37 @@ function noteApp() { // Homepage folder navigation methods goToHomepageFolder(folderPath) { - // Navigate to folder (empty string = root) this.selectedHomepageFolder = folderPath || ''; - // Update browser history - const state = { homepageFolder: folderPath || '' }; - window.history.pushState(state, '', '/'); + // Invalidate cache to force recalculation + this._homepageCache = { + folderPath: null, + notes: null, + folders: null, + breadcrumb: null + }; + + window.history.pushState({ homepageFolder: folderPath || '' }, '', '/'); + }, + + // Navigate to homepage root and clear all editor state + goHome() { + this.selectedHomepageFolder = ''; + this.currentNote = ''; + this.currentNoteName = ''; + this.noteContent = ''; + this.currentImage = ''; + this.mobileSidebarOpen = false; + + // Invalidate cache to force recalculation + this._homepageCache = { + folderPath: null, + notes: null, + folders: null, + breadcrumb: null + }; + + window.history.pushState({ homepageFolder: '' }, '', '/'); } } } diff --git a/frontend/index.html b/frontend/index.html index 224c607..0db64b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -730,7 +730,7 @@
+
@@ -997,6 +997,17 @@ notes ยท + ยท + + Website + @@ -1019,21 +1030,21 @@ @@ -1061,8 +1072,8 @@ :style="crumb.path === selectedHomepageFolder ? 'color: var(--accent-primary); font-weight: 600;' : 'color: var(--text-secondary);'" x-text="crumb.name" > - - + + @@ -1142,7 +1153,7 @@ -