Merge pull request #60 from gamosoft/features/metadata-display

added properties pane
This commit is contained in:
Gamosoft 2025-12-04 10:08:10 +01:00 committed by GitHub
commit bcbb4fb5d0
4 changed files with 435 additions and 6 deletions

View File

@ -7,7 +7,7 @@
<!-- Primary Meta Tags -->
<title>NoteDiscovery - Your Self-Hosted Knowledge Base</title>
<meta name="title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
<meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
<meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
<meta name="keywords" content="note taking, markdown editor, self-hosted, knowledge base, open source, privacy, notes app, docker, second brain, obsidian alternative, notion, evernote, onenote, latex, mermaid, diagrams, math equations, templates, tags, yaml frontmatter">
<meta name="author" content="Gamosoft">
<meta name="robots" content="index, follow">
@ -17,7 +17,7 @@
<meta property="og:type" content="website">
<meta property="og:url" content="https://www.notediscovery.com">
<meta property="og:title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
<meta property="og:description" content="A lightweight, privacy-focused Markdown note-taking application with LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
<meta property="og:description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
<meta property="og:image" content="https://www.notediscovery.com/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
@ -571,7 +571,7 @@
<div class="feature">
<div class="feature-icon">🏷️</div>
<h3>Smart Tags</h3>
<p>Organize with YAML frontmatter tags. Filter and discover notes instantly.</p>
<p>Organize with YAML frontmatter tags. Collapsible properties panel shows metadata at a glance.</p>
</div>
<div class="feature">
@ -580,6 +580,12 @@
<p>Create from custom templates with dynamic placeholders. Meeting notes, journals, and more.</p>
</div>
<div class="feature">
<div class="feature-icon">🔗</div>
<h3>Wikilinks</h3>
<p>Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.</p>
</div>
<div class="feature">
<div class="feature-icon">📂</div>
<h3>Simple Storage</h3>
@ -653,8 +659,8 @@
<div class="benefit-item">
<span class="emoji">🔗</span>
<div class="text">
<strong>Internal Links</strong>
Connect notes with backlinks
<strong>Wikilinks</strong>
[[Link notes]] Obsidian-style
</div>
</div>
@ -689,6 +695,14 @@
Password protection when needed
</div>
</div>
<div class="benefit-item">
<span class="emoji">⚙️</span>
<div class="text">
<strong>Properties Panel</strong>
View frontmatter metadata at a glance
</div>
</div>
</div>
</div>
@ -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",

View File

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

View File

@ -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 `<a href="${escaped}" target="_blank" rel="noopener noreferrer" class="metadata-link">${this.escapeHtml(displayUrl)}</a>`;
}
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');

View File

@ -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%;'"
>
<!-- Note Properties/Metadata Panel -->
<div
x-show="getHasMetadata() && currentNote"
class="note-properties mx-6 mt-4 mb-2"
>
<!-- Collapsed View -->
<div
x-show="!metadataExpanded"
@click="metadataExpanded = true"
class="flex flex-wrap items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all"
style="background-color: var(--bg-tertiary); border: 1px solid var(--border-primary);"
onmouseover="this.style.borderColor='var(--accent-primary)'"
onmouseout="this.style.borderColor='var(--border-primary)'"
>
<!-- Property icon -->
<span class="text-xs opacity-60">⚙️</span>
<!-- Tags as pills -->
<template x-for="tag in getMetadataTags().slice(0, 5)" :key="tag">
<span
@click.stop="toggleTag(tag.toLowerCase())"
class="metadata-tag px-2 py-0.5 text-xs rounded-full cursor-pointer transition-colors"
style="background-color: var(--accent-light, rgba(124, 58, 237, 0.15)); color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-primary)'; this.style.color='white'"
onmouseout="this.style.backgroundColor='var(--accent-light, rgba(124, 58, 237, 0.15))'; this.style.color='var(--accent-primary)'"
x-text="'#' + tag"
></span>
</template>
<template x-if="getMetadataTags().length > 5">
<span class="text-xs" style="color: var(--text-tertiary);">+<span x-text="getMetadataTags().length - 5"></span> more</span>
</template>
<!-- Priority fields (date, author, status) -->
<template x-for="field in getPriorityMetadataFields()" :key="field.key">
<span class="text-xs flex items-center gap-1" style="color: var(--text-secondary);">
<span class="opacity-50">·</span>
<span class="capitalize" x-text="field.key + ':'"></span>
<span style="color: var(--text-primary);" x-html="field.valueHtml" @click="field.isUrl && $event.stopPropagation()"></span>
</span>
</template>
<!-- Expand hint -->
<span class="ml-auto text-xs opacity-40"></span>
</div>
<!-- Expanded View -->
<div
x-show="metadataExpanded"
class="rounded-lg overflow-hidden"
style="background-color: var(--bg-tertiary); border: 1px solid var(--border-primary);"
>
<!-- Header -->
<div
@click="metadataExpanded = false"
class="flex items-center justify-between px-3 py-2 cursor-pointer"
style="border-bottom: 1px solid var(--border-primary);"
>
<span class="text-xs font-medium" style="color: var(--text-secondary);">
⚙️ Properties
</span>
<span class="text-xs opacity-40">▲ Collapse</span>
</div>
<!-- Properties Grid -->
<div class="px-3 py-2 space-y-2">
<!-- Tags row -->
<template x-if="getMetadataTags().length > 0">
<div class="flex flex-wrap gap-1.5 items-start">
<span class="text-xs w-16 shrink-0 pt-0.5" style="color: var(--text-tertiary);">tags</span>
<div class="flex flex-wrap gap-1">
<template x-for="tag in getMetadataTags()" :key="tag">
<span
@click="toggleTag(tag.toLowerCase())"
class="metadata-tag px-2 py-0.5 text-xs rounded-full cursor-pointer transition-colors"
style="background-color: var(--accent-light, rgba(124, 58, 237, 0.15)); color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-primary)'; this.style.color='white'"
onmouseout="this.style.backgroundColor='var(--accent-light, rgba(124, 58, 237, 0.15))'; this.style.color='var(--accent-primary)'"
x-text="'#' + tag"
></span>
</template>
</div>
</div>
</template>
<!-- Other fields -->
<template x-for="field in getAllMetadataFields()" :key="field.key">
<div class="flex gap-1.5 items-start text-xs">
<span class="w-16 shrink-0 pt-0.5" style="color: var(--text-tertiary);" x-text="field.key"></span>
<!-- Array values -->
<template x-if="field.isArray">
<div class="flex flex-wrap gap-1">
<template x-for="item in field.value" :key="item">
<span
class="px-1.5 py-0.5 rounded text-xs"
style="background-color: var(--bg-hover); color: var(--text-primary);"
x-text="item"
></span>
</template>
</div>
</template>
<!-- Single values -->
<template x-if="!field.isArray">
<span style="color: var(--text-primary);" x-html="field.valueHtml"></span>
</template>
</div>
</template>
</div>
</div>
</div>
<div
class="p-6 markdown-preview"
:class="{ 'pt-2': getHasMetadata() && currentNote }"
x-html="renderedMarkdown"
@click="handleInternalLink($event)"
></div>