diff --git a/backend/main.py b/backend/main.py index 3f3a50c..2d057e7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: 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 @@ -320,15 +322,6 @@ pages_router = APIRouter( # 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("") async def api_documentation(): """API Documentation - List all available endpoints""" @@ -1252,20 +1245,22 @@ async def health_check(): # Catch-all route for SPA (Single Page Application) routing # This allows URLs like /folder/note to work for direct navigation @pages_router.get("/{full_path:path}", response_class=HTMLResponse) +@limiter.limit("120/minute") 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) """ # 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/'): 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" async with aiofiles.open(index_path, 'r', encoding='utf-8') as f: content = await f.read() - return content + app_name = config['app']['name'] + return content.replace('NoteDiscovery', f'{app_name}') # ============================================================================ diff --git a/frontend/app.js b/frontend/app.js index d01e9e2..8595b8b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -164,6 +164,10 @@ function noteApp() { 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 _previewDebounceTimeout: null, _lastRenderedContent: '', @@ -454,6 +458,7 @@ function noteApp() { // Set initial homepage state ONLY if we're actually on the homepage if (window.location.pathname === '/') { window.history.replaceState({ homepageFolder: '' }, '', '/'); + document.title = this.appName; } // Listen for browser back/forward navigation @@ -478,7 +483,7 @@ function noteApp() { this.noteContent = ''; this.currentNoteName = ''; this.outline = []; - document.title = 'NoteDiscovery'; + document.title = this.appName; // Restore homepage folder state if it was saved if (e.state && e.state.homepageFolder !== undefined) { @@ -1003,12 +1008,25 @@ function noteApp() { this._noteLookup.byName.clear(); this._noteLookup.byNameLower.clear(); this._noteLookup.byEndPath.clear(); + this._imageLookup.clear(); for (const note of this.notes) { const path = note.path; const pathLower = path.toLowerCase(); const name = note.name; 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 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 async loadTags() { try { @@ -1993,10 +2018,9 @@ function noteApp() { let link; if (isImage) { - // For images, insert image markdown - const filename = notePath.split('/').pop().replace(/\.[^/.]+$/, ''); // Remove extension - // Use relative path (not /api/images/) for portability - link = `![${filename}](${notePath})`; + // For images, use wiki-style link (resolves by filename, never breaks) + const filename = notePath.split('/').pop(); + link = `![[${filename}]]`; } else { // For notes, insert note link 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) { - const filename = altText.replace(/\.[^/.]+$/, ''); // Remove extension - // Use relative path (not /api/images/) for portability - const markdown = `![${filename}](${imagePath})`; + // Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png") + const filename = imagePath.split('/').pop(); + + // 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 textAfter = this.noteContent.substring(cursorPos); @@ -2097,7 +2131,7 @@ function noteApp() { // Trigger 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(); }, @@ -2159,7 +2193,7 @@ function noteApp() { // Update browser tab title for image const imageName = imagePath.split('/').pop(); - document.title = `${imageName} - NoteDiscovery`; + document.title = `${imageName} - ${this.appName}`; // Update browser URL if (updateHistory) { @@ -2475,7 +2509,7 @@ function noteApp() { this.currentNote = ''; this.noteContent = ''; this.currentImage = ''; - document.title = 'NoteDiscovery'; + document.title = this.appName; return; } throw new Error(`HTTP error! status: ${response.status}`); @@ -2491,7 +2525,7 @@ function noteApp() { this.currentImage = ''; // Clear image viewer when loading a note // Update browser tab title - document.title = `${this.currentNoteName} - NoteDiscovery`; + document.title = `${this.currentNoteName} - ${this.appName}`; this.lastSaved = false; // Extract outline for TOC panel @@ -3076,7 +3110,7 @@ function noteApp() { if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { this.currentNote = ''; this.noteContent = ''; - document.title = 'NoteDiscovery'; + document.title = this.appName; } await this.loadNotes(); @@ -3570,7 +3604,7 @@ function noteApp() { this.currentNoteName = ''; this._lastRenderedContent = ''; // Clear render cache this._cachedRenderedHTML = ''; - document.title = 'NoteDiscovery'; + document.title = this.appName; // Redirect to root window.history.replaceState({}, '', '/'); } @@ -3746,7 +3780,54 @@ function noteApp() { // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] // 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 + + // 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, '"'); + return `${safeAlt}`; + } + + // Image not found - return broken image indicator + const safeFilename = filename.replace(/&/g, '&').replace(//g, '>'); + return `🖼️ ${safeFilename}`; + } + ); + + // Step 2b: Convert note wikilinks: [[note]] or [[note|display text]] contentToRender = contentToRender.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (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 marked.setOptions({ breaks: true, @@ -3888,22 +3974,35 @@ function noteApp() { // Add copy button to code block 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'); button.className = 'copy-code-button'; - button.innerHTML = ` - - - - - `; - button.title = 'Copy to clipboard'; + const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback + button.innerHTML = `${displayText}`; + button.dataset.originalText = displayText; // Store for restore after copy + button.title = this.t('common.copy_to_clipboard'); // Style the button button.style.position = 'absolute'; button.style.top = '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.border = 'none'; button.style.borderRadius = '4px'; @@ -3915,6 +4014,11 @@ function noteApp() { button.style.alignItems = 'center'; button.style.justifyContent = 'center'; 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 preElement.style.position = 'relative'; @@ -3938,28 +4042,23 @@ function noteApp() { const code = codeElement.textContent; + const originalText = button.dataset.originalText; + const copiedText = this.t('common.copied'); + const copyTitle = this.t('common.copy_to_clipboard'); + try { await navigator.clipboard.writeText(code); - // Visual feedback - change icon to checkmark - button.innerHTML = ` - - - - `; + // Visual feedback - show localized "Copied!" + button.innerHTML = `${copiedText}`; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; - button.title = 'Copied!'; + button.title = copiedText; // Reset after 2 seconds setTimeout(() => { - button.innerHTML = ` - - - - - `; + button.innerHTML = `${originalText}`; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; - button.title = 'Copy to clipboard'; + button.title = copyTitle; }, 2000); } catch (err) { console.error('Failed to copy code:', err); @@ -3974,19 +4073,10 @@ function noteApp() { try { document.execCommand('copy'); - button.innerHTML = ` - - - - `; + button.innerHTML = `${copiedText}`; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; setTimeout(() => { - button.innerHTML = ` - - - - - `; + button.innerHTML = `${originalText}`; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; }, 2000); } catch (fallbackErr) { @@ -4966,7 +5056,7 @@ function noteApp() { this.noteContent = ''; this.currentImage = ''; this.outline = []; - document.title = 'NoteDiscovery'; + document.title = this.appName; // Invalidate cache to force recalculation this._homepageCache = { @@ -4989,7 +5079,7 @@ function noteApp() { this.currentImage = ''; this.outline = []; this.mobileSidebarOpen = false; - document.title = 'NoteDiscovery'; + document.title = this.appName; // Clear undo/redo history this.undoHistory = []; diff --git a/locales/de-DE.json b/locales/de-DE.json index 7b1307b..746b4c9 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -23,7 +23,8 @@ "loading": "Laden...", "saved": "✓ Gespeichert", "saving": "Speichern...", - "copied": "✓ Kopiert!", + "copied": "Kopiert!", + "copy_to_clipboard": "In Zwischenablage kopieren", "failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut." }, diff --git a/locales/en-GB.json b/locales/en-GB.json index 0880747..b5bc797 100644 --- a/locales/en-GB.json +++ b/locales/en-GB.json @@ -23,7 +23,8 @@ "loading": "Loading...", "saved": "✓ Saved", "saving": "Saving...", - "copied": "✓ Copied!", + "copied": "Copied!", + "copy_to_clipboard": "Copy to clipboard", "failed": "Failed to {{action}}. Please try again." }, diff --git a/locales/en-US.json b/locales/en-US.json index 10ede19..9c35a5c 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -23,7 +23,8 @@ "loading": "Loading...", "saved": "✓ Saved", "saving": "Saving...", - "copied": "✓ Copied!", + "copied": "Copied!", + "copy_to_clipboard": "Copy to clipboard", "failed": "Failed to {{action}}. Please try again." }, diff --git a/locales/es-ES.json b/locales/es-ES.json index 6d3fe51..bb29a64 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -23,7 +23,8 @@ "loading": "Cargando...", "saved": "✓ Guardado", "saving": "Guardando...", - "copied": "✓ ¡Copiado!", + "copied": "¡Copiado!", + "copy_to_clipboard": "Copiar al portapapeles", "failed": "Error al {{action}}. Por favor, inténtalo de nuevo." }, diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 34009d5..26c35e9 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -23,7 +23,8 @@ "loading": "Chargement...", "saved": "✓ Enregistré", "saving": "Enregistrement...", - "copied": "✓ Copié !", + "copied": "Copié !", + "copy_to_clipboard": "Copier dans le presse-papiers", "failed": "Échec de {{action}}. Veuillez réessayer." }, diff --git a/locales/sl-SI.json b/locales/sl-SI.json index ad72096..2e115f8 100644 --- a/locales/sl-SI.json +++ b/locales/sl-SI.json @@ -23,7 +23,8 @@ "loading": "Nalaganje...", "saved": "✓ Shranjeno", "saving": "Shranjevanje...", - "copied": "✓ Kopirano!", + "copied": "Kopirano!", + "copy_to_clipboard": "Kopiraj v odložišče", "failed": "Napaka pri {{action}}. Prosimo, poskusite znova." }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f4daae1..d298624 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -23,7 +23,8 @@ "loading": "加载中...", "saved": "✓ 已保存", "saving": "保存中...", - "copied": "✓ 已复制!", + "copied": "已复制!", + "copy_to_clipboard": "复制到剪贴板", "failed": "{{action}} 失败。请重试。" },