Merge pull request #139 from gamosoft/features/code-blocks

Features/code blocks
This commit is contained in:
Guillermo Villar 2026-01-05 12:51:39 +01:00 committed by GitHub
commit 9c02ad0e3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 163 additions and 71 deletions

View File

@ -269,7 +269,9 @@ async def login_page(request: Request, error: str = None):
async with aiofiles.open(login_path, 'r', encoding='utf-8') as f: async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
content = await f.read() content = await f.read()
# No server-side manipulation needed - frontend handles error display via URL params # Inject app name throughout the login page
app_name = config['app']['name']
content = content.replace('NoteDiscovery', app_name)
return content return content
@ -320,15 +322,6 @@ pages_router = APIRouter(
# Application Routes (with auth via router dependencies) # Application Routes (with auth via router dependencies)
# ============================================================================ # ============================================================================
@pages_router.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Serve the main application page"""
index_path = static_path / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
content = await f.read()
return content
@api_router.get("") @api_router.get("")
async def api_documentation(): async def api_documentation():
"""API Documentation - List all available endpoints""" """API Documentation - List all available endpoints"""
@ -1252,20 +1245,22 @@ async def health_check():
# Catch-all route for SPA (Single Page Application) routing # Catch-all route for SPA (Single Page Application) routing
# This allows URLs like /folder/note to work for direct navigation # This allows URLs like /folder/note to work for direct navigation
@pages_router.get("/{full_path:path}", response_class=HTMLResponse) @pages_router.get("/{full_path:path}", response_class=HTMLResponse)
@limiter.limit("120/minute")
async def catch_all(full_path: str, request: Request): async def catch_all(full_path: str, request: Request):
""" """
Serve index.html for all non-API routes. Serve index.html for all non-API routes (including root /).
This enables client-side routing (e.g., /folder/note) This enables client-side routing (e.g., /folder/note)
""" """
# Skip if it's an API route or static file (shouldn't reach here, but just in case) # Skip if it's an API route or static file (shouldn't reach here, but just in case)
if full_path.startswith('api/') or full_path.startswith('static/'): if full_path.startswith('api/') or full_path.startswith('static/'):
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
# Serve index.html for all other routes # Serve index.html with app name injected
index_path = static_path / "index.html" index_path = static_path / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f: async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
content = await f.read() content = await f.read()
return content app_name = config['app']['name']
return content.replace('<title>NoteDiscovery</title>', f'<title>{app_name}</title>')
# ============================================================================ # ============================================================================

View File

@ -164,6 +164,10 @@ function noteApp() {
byEndPath: new Map(), // '/filename' and '/filename.md' -> true byEndPath: new Map(), // '/filename' and '/filename.md' -> true
}, },
// Image lookup map for O(1) image wikilink resolution (built on loadNotes)
// Maps image filename (case-insensitive) -> full path
_imageLookup: new Map(),
// Preview rendering debounce // Preview rendering debounce
_previewDebounceTimeout: null, _previewDebounceTimeout: null,
_lastRenderedContent: '', _lastRenderedContent: '',
@ -454,6 +458,7 @@ function noteApp() {
// Set initial homepage state ONLY if we're actually on the homepage // Set initial homepage state ONLY if we're actually on the homepage
if (window.location.pathname === '/') { if (window.location.pathname === '/') {
window.history.replaceState({ homepageFolder: '' }, '', '/'); window.history.replaceState({ homepageFolder: '' }, '', '/');
document.title = this.appName;
} }
// Listen for browser back/forward navigation // Listen for browser back/forward navigation
@ -478,7 +483,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.outline = []; this.outline = [];
document.title = 'NoteDiscovery'; document.title = this.appName;
// Restore homepage folder state if it was saved // Restore homepage folder state if it was saved
if (e.state && e.state.homepageFolder !== undefined) { if (e.state && e.state.homepageFolder !== undefined) {
@ -1003,12 +1008,25 @@ function noteApp() {
this._noteLookup.byName.clear(); this._noteLookup.byName.clear();
this._noteLookup.byNameLower.clear(); this._noteLookup.byNameLower.clear();
this._noteLookup.byEndPath.clear(); this._noteLookup.byEndPath.clear();
this._imageLookup.clear();
for (const note of this.notes) { for (const note of this.notes) {
const path = note.path; const path = note.path;
const pathLower = path.toLowerCase(); const pathLower = path.toLowerCase();
const name = note.name; const name = note.name;
const nameLower = name.toLowerCase(); const nameLower = name.toLowerCase();
// Handle images separately - build image lookup map
if (note.type === 'image') {
// Map filename (case-insensitive) to full path
// First match wins if there are duplicates
if (!this._imageLookup.has(nameLower)) {
this._imageLookup.set(nameLower, path);
}
continue;
}
// Notes only from here
const nameWithoutMd = name.replace(/\.md$/i, ''); const nameWithoutMd = name.replace(/\.md$/i, '');
const nameWithoutMdLower = nameWithoutMd.toLowerCase(); const nameWithoutMdLower = nameWithoutMd.toLowerCase();
@ -1045,6 +1063,13 @@ function noteApp() {
); );
}, },
// Resolve image wikilink to full path (O(1) lookup)
// Returns the full path if found, null otherwise
resolveImageWikilink(imageName) {
const nameLower = imageName.toLowerCase();
return this._imageLookup.get(nameLower) || null;
},
// Load all tags // Load all tags
async loadTags() { async loadTags() {
try { try {
@ -1993,10 +2018,9 @@ function noteApp() {
let link; let link;
if (isImage) { if (isImage) {
// For images, insert image markdown // For images, use wiki-style link (resolves by filename, never breaks)
const filename = notePath.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension const filename = notePath.split('/').pop();
// Use relative path (not /api/images/) for portability link = `![[${filename}]]`;
link = `![${filename}](${notePath})`;
} else { } else {
// For notes, insert note link // For notes, insert note link
const noteName = notePath.split('/').pop().replace('.md', ''); const noteName = notePath.split('/').pop().replace('.md', '');
@ -2083,11 +2107,21 @@ function noteApp() {
} }
}, },
// Insert image markdown at cursor position // Insert image markdown at cursor position using wiki-style syntax
// This ensures image links don't break when notes are moved
insertImageMarkdown(imagePath, altText, cursorPos) { insertImageMarkdown(imagePath, altText, cursorPos) {
const filename = altText.replace(/\.[^/.]+$/, ''); // Remove extension // Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
// Use relative path (not /api/images/) for portability const filename = imagePath.split('/').pop();
const markdown = `![${filename}](${imagePath})`;
// Use wiki-style image link: ![[filename.png]] or ![[filename.png|alt text]]
// The alt text is optional - only add if different from filename
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
// If alt text is meaningful (not just "pasted-image"), include it
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
? `![[${filename}|${altWithoutExt}]]`
: `![[${filename}]]`;
const textBefore = this.noteContent.substring(0, cursorPos); const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos); const textAfter = this.noteContent.substring(cursorPos);
@ -2097,7 +2131,7 @@ function noteApp() {
// Trigger autosave // Trigger autosave
this.autoSave(); this.autoSave();
// Reload notes to show the new image in sidebar // Reload notes to show the new image in sidebar and update lookup maps
this.loadNotes(); this.loadNotes();
}, },
@ -2159,7 +2193,7 @@ function noteApp() {
// Update browser tab title for image // Update browser tab title for image
const imageName = imagePath.split('/').pop(); const imageName = imagePath.split('/').pop();
document.title = `${imageName} - NoteDiscovery`; document.title = `${imageName} - ${this.appName}`;
// Update browser URL // Update browser URL
if (updateHistory) { if (updateHistory) {
@ -2475,7 +2509,7 @@ function noteApp() {
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentImage = '';
document.title = 'NoteDiscovery'; document.title = this.appName;
return; return;
} }
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@ -2491,7 +2525,7 @@ function noteApp() {
this.currentImage = ''; // Clear image viewer when loading a note this.currentImage = ''; // Clear image viewer when loading a note
// Update browser tab title // Update browser tab title
document.title = `${this.currentNoteName} - NoteDiscovery`; document.title = `${this.currentNoteName} - ${this.appName}`;
this.lastSaved = false; this.lastSaved = false;
// Extract outline for TOC panel // Extract outline for TOC panel
@ -3076,7 +3110,7 @@ function noteApp() {
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
document.title = 'NoteDiscovery'; document.title = this.appName;
} }
await this.loadNotes(); await this.loadNotes();
@ -3570,7 +3604,7 @@ function noteApp() {
this.currentNoteName = ''; this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache this._lastRenderedContent = ''; // Clear render cache
this._cachedRenderedHTML = ''; this._cachedRenderedHTML = '';
document.title = 'NoteDiscovery'; document.title = this.appName;
// Redirect to root // Redirect to root
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
} }
@ -3746,7 +3780,54 @@ function noteApp() {
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
// Must be done before marked.parse() to avoid conflicts with markdown syntax // Must be done before marked.parse() to avoid conflicts with markdown syntax
// BUT we need to protect code blocks first to avoid converting [[text]] inside code
const self = this; // Reference for closure const self = this; // Reference for closure
// Step 1: Temporarily replace code blocks and inline code with placeholders
const codeBlocks = [];
// Protect fenced code blocks (```...```)
contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => {
codeBlocks.push(match);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Protect inline code (`...`)
contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
codeBlocks.push(match);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Step 2: Convert image wikilinks FIRST: ![[image.png]] or ![[image.png|alt text]]
// Must be before note wikilinks to prevent [[image.png]] from being matched first
contentToRender = contentToRender.replace(
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, imageName, altText) => {
const filename = imageName.trim();
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
// Resolve image path using O(1) lookup
const imagePath = self.resolveImageWikilink(filename);
if (imagePath) {
// URL-encode path segments for the API
const encodedPath = imagePath.split('/').map(segment => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch (e) {
return encodeURIComponent(segment);
}
}).join('/');
const safeAlt = alt.replace(/"/g, '&quot;');
return `<img src="/api/images/${encodedPath}" alt="${safeAlt}" title="${safeAlt}">`;
}
// Image not found - return broken image indicator
const safeFilename = filename.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<span class="wikilink-broken" title="Image not found">🖼️ ${safeFilename}</span>`;
}
);
// Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
contentToRender = contentToRender.replace( contentToRender = contentToRender.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, target, displayText) => { (match, target, displayText) => {
@ -3769,6 +3850,11 @@ function noteApp() {
} }
); );
// Step 3: Restore code blocks
contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
return codeBlocks[parseInt(index)];
});
// Configure marked with syntax highlighting // Configure marked with syntax highlighting
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
@ -3888,22 +3974,35 @@ function noteApp() {
// Add copy button to code block // Add copy button to code block
addCopyButtonToCodeBlock(preElement) { addCopyButtonToCodeBlock(preElement) {
// Create copy button // Extract language from code element class (e.g., "language-toml" -> "TOML")
const codeElement = preElement.querySelector('code');
let language = '';
if (codeElement && codeElement.className) {
const match = codeElement.className.match(/language-(\w+)/);
if (match) {
const langMap = {
'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python',
'rb': 'Ruby', 'cs': 'C#', 'cpp': 'C++', 'sh': 'Shell',
'bash': 'Bash', 'zsh': 'Zsh', 'yml': 'YAML', 'md': 'Markdown'
};
const rawLang = match[1].toLowerCase();
language = langMap[rawLang] || match[1].toUpperCase();
}
}
// Create copy button with language label
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'copy-code-button'; button.className = 'copy-code-button';
button.innerHTML = ` const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> button.innerHTML = `<span>${displayText}</span>`;
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> button.dataset.originalText = displayText; // Store for restore after copy
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> button.title = this.t('common.copy_to_clipboard');
</svg>
`;
button.title = 'Copy to clipboard';
// Style the button // Style the button
button.style.position = 'absolute'; button.style.position = 'absolute';
button.style.top = '8px'; button.style.top = '8px';
button.style.right = '8px'; button.style.right = '8px';
button.style.padding = '6px'; button.style.padding = '4px 10px';
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
button.style.border = 'none'; button.style.border = 'none';
button.style.borderRadius = '4px'; button.style.borderRadius = '4px';
@ -3915,6 +4014,11 @@ function noteApp() {
button.style.alignItems = 'center'; button.style.alignItems = 'center';
button.style.justifyContent = 'center'; button.style.justifyContent = 'center';
button.style.zIndex = '10'; button.style.zIndex = '10';
button.style.fontSize = '11px';
button.style.fontWeight = '600';
button.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
button.style.textTransform = 'uppercase';
button.style.letterSpacing = '0.5px';
// Style the pre element to be relative // Style the pre element to be relative
preElement.style.position = 'relative'; preElement.style.position = 'relative';
@ -3938,28 +4042,23 @@ function noteApp() {
const code = codeElement.textContent; const code = codeElement.textContent;
const originalText = button.dataset.originalText;
const copiedText = this.t('common.copied');
const copyTitle = this.t('common.copy_to_clipboard');
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
// Visual feedback - change icon to checkmark // Visual feedback - show localized "Copied!"
button.innerHTML = ` button.innerHTML = `<span>${copiedText}</span>`;
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
button.title = 'Copied!'; button.title = copiedText;
// Reset after 2 seconds // Reset after 2 seconds
setTimeout(() => { setTimeout(() => {
button.innerHTML = ` button.innerHTML = `<span>${originalText}</span>`;
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
button.title = 'Copy to clipboard'; button.title = copyTitle;
}, 2000); }, 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy code:', err); console.error('Failed to copy code:', err);
@ -3974,19 +4073,10 @@ function noteApp() {
try { try {
document.execCommand('copy'); document.execCommand('copy');
button.innerHTML = ` button.innerHTML = `<span>${copiedText}</span>`;
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
setTimeout(() => { setTimeout(() => {
button.innerHTML = ` button.innerHTML = `<span>${originalText}</span>`;
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
}, 2000); }, 2000);
} catch (fallbackErr) { } catch (fallbackErr) {
@ -4966,7 +5056,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentImage = '';
this.outline = []; this.outline = [];
document.title = 'NoteDiscovery'; document.title = this.appName;
// Invalidate cache to force recalculation // Invalidate cache to force recalculation
this._homepageCache = { this._homepageCache = {
@ -4989,7 +5079,7 @@ function noteApp() {
this.currentImage = ''; this.currentImage = '';
this.outline = []; this.outline = [];
this.mobileSidebarOpen = false; this.mobileSidebarOpen = false;
document.title = 'NoteDiscovery'; document.title = this.appName;
// Clear undo/redo history // Clear undo/redo history
this.undoHistory = []; this.undoHistory = [];

View File

@ -23,7 +23,8 @@
"loading": "Laden...", "loading": "Laden...",
"saved": "✓ Gespeichert", "saved": "✓ Gespeichert",
"saving": "Speichern...", "saving": "Speichern...",
"copied": "✓ Kopiert!", "copied": "Kopiert!",
"copy_to_clipboard": "In Zwischenablage kopieren",
"failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut." "failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut."
}, },

View File

@ -23,7 +23,8 @@
"loading": "Loading...", "loading": "Loading...",
"saved": "✓ Saved", "saved": "✓ Saved",
"saving": "Saving...", "saving": "Saving...",
"copied": "✓ Copied!", "copied": "Copied!",
"copy_to_clipboard": "Copy to clipboard",
"failed": "Failed to {{action}}. Please try again." "failed": "Failed to {{action}}. Please try again."
}, },

View File

@ -23,7 +23,8 @@
"loading": "Loading...", "loading": "Loading...",
"saved": "✓ Saved", "saved": "✓ Saved",
"saving": "Saving...", "saving": "Saving...",
"copied": "✓ Copied!", "copied": "Copied!",
"copy_to_clipboard": "Copy to clipboard",
"failed": "Failed to {{action}}. Please try again." "failed": "Failed to {{action}}. Please try again."
}, },

View File

@ -23,7 +23,8 @@
"loading": "Cargando...", "loading": "Cargando...",
"saved": "✓ Guardado", "saved": "✓ Guardado",
"saving": "Guardando...", "saving": "Guardando...",
"copied": "✓ ¡Copiado!", "copied": "¡Copiado!",
"copy_to_clipboard": "Copiar al portapapeles",
"failed": "Error al {{action}}. Por favor, inténtalo de nuevo." "failed": "Error al {{action}}. Por favor, inténtalo de nuevo."
}, },

View File

@ -23,7 +23,8 @@
"loading": "Chargement...", "loading": "Chargement...",
"saved": "✓ Enregistré", "saved": "✓ Enregistré",
"saving": "Enregistrement...", "saving": "Enregistrement...",
"copied": "✓ Copié !", "copied": "Copié !",
"copy_to_clipboard": "Copier dans le presse-papiers",
"failed": "Échec de {{action}}. Veuillez réessayer." "failed": "Échec de {{action}}. Veuillez réessayer."
}, },

View File

@ -23,7 +23,8 @@
"loading": "Nalaganje...", "loading": "Nalaganje...",
"saved": "✓ Shranjeno", "saved": "✓ Shranjeno",
"saving": "Shranjevanje...", "saving": "Shranjevanje...",
"copied": "✓ Kopirano!", "copied": "Kopirano!",
"copy_to_clipboard": "Kopiraj v odložišče",
"failed": "Napaka pri {{action}}. Prosimo, poskusite znova." "failed": "Napaka pri {{action}}. Prosimo, poskusite znova."
}, },

View File

@ -23,7 +23,8 @@
"loading": "加载中...", "loading": "加载中...",
"saved": "✓ 已保存", "saved": "✓ 已保存",
"saving": "保存中...", "saving": "保存中...",
"copied": "✓ 已复制!", "copied": "已复制!",
"copy_to_clipboard": "复制到剪贴板",
"failed": "{{action}} 失败。请重试。" "failed": "{{action}} 失败。请重试。"
}, },