From abe30a06eeb88cf54b0e2df9c441e103f5bb73be Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Thu, 4 Dec 2025 10:07:07 +0100 Subject: [PATCH] added properties pane --- docs/index.html | 26 +++- documentation/FEATURES.md | 36 ++++++ frontend/app.js | 250 ++++++++++++++++++++++++++++++++++++++ frontend/index.html | 129 ++++++++++++++++++++ 4 files changed, 435 insertions(+), 6 deletions(-) diff --git a/docs/index.html b/docs/index.html index f8dbcc8..1065f16 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,7 +7,7 @@ NoteDiscovery - Your Self-Hosted Knowledge Base - + @@ -17,7 +17,7 @@ - + @@ -571,7 +571,7 @@
๐Ÿท๏ธ

Smart Tags

-

Organize with YAML frontmatter tags. Filter and discover notes instantly.

+

Organize with YAML frontmatter tags. Collapsible properties panel shows metadata at a glance.

@@ -580,6 +580,12 @@

Create from custom templates with dynamic placeholders. Meeting notes, journals, and more.

+
+
๐Ÿ”—
+

Wikilinks

+

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

+
+
๐Ÿ“‚

Simple Storage

@@ -653,8 +659,8 @@
๐Ÿ”—
- Internal Links - Connect notes with backlinks + Wikilinks + [[Link notes]] Obsidian-style
@@ -689,6 +695,14 @@ Password protection when needed
+ +
+ โš™๏ธ +
+ Properties Panel + View frontmatter metadata at a glance +
+
@@ -745,7 +759,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 links, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", + "featureList": "Markdown editor, Wikilinks, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, 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 e44341b..db1fb86 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -98,6 +98,42 @@ tags: [python, tutorial, backend] - **Collapsible panel** - Saves state across sessions - **Auto-sync** - Updates after saving notes +## โš™๏ธ Note Properties Panel + +View and interact with YAML frontmatter metadata directly in the preview. + +### Features +- **Collapsible panel** - Compact bar at the top of preview, expands on click +- **Auto-hides** - Only appears when note has frontmatter +- **Clickable tags** - Filter notes by clicking any tag +- **Smart formatting** - Dates formatted nicely, booleans shown as โœ“/โœ— +- **URL detection** - Links in metadata are clickable +- **Real-time updates** - Changes as you edit frontmatter +- **Performance optimized** - Cached parsing, no re-parse if unchanged + +### Collapsed View +Shows tags as pills plus up to 3 priority fields (date, author, status, etc.) + +### Expanded View +Click to expand and see all metadata fields in a clean grid layout. + +### Supported Formats +```yaml +--- +tags: [project, important] # Inline array +date: 2024-01-15 # Formatted as "Jan 15, 2024" +author: John Doe # String value +status: draft # String value +priority: high # String value +source: https://example.com # Clickable link +draft: true # Shows as "โœ“ Yes" +custom-field: any value # Keys with hyphens supported +items: # YAML list format + - item 1 + - item 2 +--- +``` + ## ๐Ÿ” Search & Filtering ### Text Search diff --git a/frontend/app.js b/frontend/app.js index 0518248..4212642 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -86,6 +86,11 @@ function noteApp() { noteStats: null, statsExpanded: false, + // Note metadata (frontmatter) state + noteMetadata: null, + metadataExpanded: false, + _lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter + // Sidebar resize state sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH, isResizing: false, @@ -1709,6 +1714,9 @@ function noteApp() { this.calculateStats(); } + // Parse frontmatter metadata + this.parseMetadata(); + // Store search query for highlighting if (searchQuery) { this.currentSearchHighlight = searchQuery; @@ -2253,6 +2261,9 @@ function noteApp() { this.calculateStats(); } + // Parse metadata in real-time + this.parseMetadata(); + this.saveTimeout = setTimeout(() => { this.saveNote(); }, CONFIG.AUTOSAVE_DELAY); @@ -3099,6 +3110,245 @@ function noteApp() { }; }, + // Parse YAML frontmatter metadata from note content + parseMetadata() { + if (!this.noteContent) { + this.noteMetadata = null; + this._lastFrontmatter = null; + return; + } + + const content = this.noteContent; + + // Check if content starts with frontmatter + if (!content.trim().startsWith('---')) { + this.noteMetadata = null; + this._lastFrontmatter = null; + return; + } + + try { + const lines = content.split('\n'); + if (lines[0].trim() !== '---') { + this.noteMetadata = null; + this._lastFrontmatter = null; + return; + } + + // Find closing --- + let endIdx = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + endIdx = i; + break; + } + } + + if (endIdx === -1) { + this.noteMetadata = null; + this._lastFrontmatter = null; + return; + } + + // Performance optimization: skip parsing if frontmatter unchanged + const frontmatterRaw = lines.slice(0, endIdx + 1).join('\n'); + if (frontmatterRaw === this._lastFrontmatter) { + return; // No change, keep existing metadata + } + this._lastFrontmatter = frontmatterRaw; + + const frontmatterLines = lines.slice(1, endIdx); + const metadata = {}; + let currentKey = null; + let currentValue = []; + + for (const line of frontmatterLines) { + // Check for new key: value pair (supports keys with hyphens/underscores) + const keyMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); + + if (keyMatch) { + // Save previous key if exists + if (currentKey) { + metadata[currentKey] = this.parseYamlValue(currentValue.join('\n')); + } + + currentKey = keyMatch[1]; + const value = keyMatch[2].trim(); + currentValue = [value]; + } else if (line.match(/^\s+-\s+/) && currentKey) { + // List item continuation (e.g., " - item") + currentValue.push(line); + } else if (line.startsWith(' ') && currentKey) { + // Indented content (multiline value) + currentValue.push(line); + } + } + + // Save last key + if (currentKey) { + metadata[currentKey] = this.parseYamlValue(currentValue.join('\n')); + } + + this.noteMetadata = Object.keys(metadata).length > 0 ? metadata : null; + + } catch (error) { + console.error('Failed to parse frontmatter:', error); + this.noteMetadata = null; + this._lastFrontmatter = null; + } + }, + + // Parse a YAML value (handles arrays, strings, numbers, booleans) + parseYamlValue(value) { + if (!value || value.trim() === '') return null; + + value = value.trim(); + + // Check for inline array: [item1, item2] + if (value.startsWith('[') && value.endsWith(']')) { + const inner = value.slice(1, -1); + return inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s); + } + + // Check for YAML list format (multiple lines starting with -) + if (value.includes('\n -') || value.startsWith(' -')) { + const items = []; + const lines = value.split('\n'); + for (const line of lines) { + const match = line.match(/^\s*-\s*(.+)$/); + if (match) { + items.push(match[1].trim().replace(/^["']|["']$/g, '')); + } + } + return items.length > 0 ? items : value; + } + + // Check for boolean + if (value.toLowerCase() === 'true') return true; + if (value.toLowerCase() === 'false') return false; + + // Check for number + if (/^-?\d+(\.\d+)?$/.test(value)) { + return parseFloat(value); + } + + // Return as string (remove quotes if present) + return value.replace(/^["']|["']$/g, ''); + }, + + // Check if a string is a URL + isUrl(str) { + if (typeof str !== 'string') return false; + return /^https?:\/\/\S+$/i.test(str.trim()); + }, + + // Escape HTML to prevent XSS + escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }, + + // Format metadata value for display + formatMetadataValue(key, value) { + if (value === null || value === undefined) return ''; + + // Arrays are handled separately in the template + if (Array.isArray(value)) return value; + + // Format dates nicely + if (key === 'date' || key === 'created' || key === 'modified' || key === 'updated') { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + } + + // Booleans + if (typeof value === 'boolean') { + return value ? 'โœ“ Yes' : 'โœ— No'; + } + + return String(value); + }, + + // Format metadata value as HTML (for URL support) + formatMetadataValueHtml(key, value) { + const formatted = this.formatMetadataValue(key, value); + + // Check if it's a URL + if (this.isUrl(formatted)) { + const escaped = this.escapeHtml(formatted); + // Truncate long URLs for display + const displayUrl = formatted.length > 40 + ? formatted.substring(0, 37) + '...' + : formatted; + return `${this.escapeHtml(displayUrl)}`; + } + + return this.escapeHtml(formatted); + }, + + // Get priority metadata fields (shown in collapsed view) + getPriorityMetadataFields() { + if (!this.noteMetadata) return []; + + // Fields to show in collapsed view, in order of priority + const priority = ['date', 'created', 'author', 'status', 'priority', 'type', 'category']; + const fields = []; + + for (const key of priority) { + if (this.noteMetadata[key] !== undefined && !Array.isArray(this.noteMetadata[key])) { + const formatted = this.formatMetadataValue(key, this.noteMetadata[key]); + const isUrl = this.isUrl(formatted); + fields.push({ + key, + value: formatted, + valueHtml: isUrl ? this.formatMetadataValueHtml(key, this.noteMetadata[key]) : this.escapeHtml(formatted), + isUrl + }); + } + } + + return fields.slice(0, 3); // Show max 3 fields in collapsed view + }, + + // Get all metadata fields except tags (for expanded view) + getAllMetadataFields() { + if (!this.noteMetadata) return []; + + return Object.entries(this.noteMetadata) + .filter(([key]) => key !== 'tags') // Tags shown separately + .map(([key, value]) => { + const isArray = Array.isArray(value); + const formatted = this.formatMetadataValue(key, value); + const isUrl = !isArray && this.isUrl(formatted); + return { + key, + value: formatted, + valueHtml: isUrl ? this.formatMetadataValueHtml(key, value) : this.escapeHtml(formatted), + isArray, + isUrl + }; + }); + }, + + // Check if note has any displayable metadata + getHasMetadata() { + const has = this.noteMetadata && Object.keys(this.noteMetadata).length > 0; + return has; + }, + + // Get tags from metadata + getMetadataTags() { + if (!this.noteMetadata || !this.noteMetadata.tags) return []; + return Array.isArray(this.noteMetadata.tags) ? this.noteMetadata.tags : [this.noteMetadata.tags]; + }, + // Load sidebar width from localStorage loadSidebarWidth() { const saved = localStorage.getItem('sidebarWidth'); diff --git a/frontend/index.html b/frontend/index.html index cabf349..503a999 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -290,6 +290,24 @@ opacity: 1; } + /* Note Properties Panel */ + .note-properties { + font-size: 0.8125rem; + line-height: 1.4; + } + .note-properties .metadata-tag { + font-weight: 500; + } + .note-properties .metadata-link { + color: var(--accent-primary); + text-decoration: none !important; + border-bottom: 1px dashed var(--accent-primary); + transition: border-bottom-style 0.15s; + } + .note-properties .metadata-link:hover { + border-bottom-style: solid; + } + /* Standard internal links (non-wikilink, e.g. [text](path)) */ .markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) { color: var(--accent-primary); @@ -1498,8 +1516,119 @@ style="background-color: var(--bg-primary); min-height: 0;" :style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'" > + +
+ +
+ + โš™๏ธ + + + + + + + + + + โ–ผ +
+ + +
+ +
+ + โš™๏ธ Properties + + โ–ฒ Collapse +
+ + +
+ + + + + +
+
+
+