From 42ad089b04e6e853ce81ea77f9611bd086d45ed8 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 12 Apr 2026 18:46:35 +0200 Subject: [PATCH 1/5] added continuation features --- documentation/FEATURES.md | 1 + frontend/app.js | 25 ++++++ frontend/editor-markdown-continue.js | 124 +++++++++++++++++++++++++++ frontend/index.html | 2 + 4 files changed, 152 insertions(+) create mode 100644 frontend/editor-markdown-continue.js diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index e453aa1..7e53e1e 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -32,6 +32,7 @@ - **Visual tree view** - Expandable/collapsible navigation - **Hide system folders** - Toggle to hide `_attachments`, `_templates` and other underscore-prefixed folders from sidebar - **Tab inserts tab** - Toggle to make Tab key insert a tab character in the editor instead of changing focus +- **List & quote continuation (Enter)** - At the end of a line, Enter continues blockquotes (`>`), bullets (`-` / `*` / `+`), and task lists (`- [ ]` / `- [x]`); pressing Enter again on an empty continued line exits the list or quote. Disabled inside fenced code blocks and when using Shift+Enter (plain newline) ### Export & Print - **HTML Export** - Download notes as standalone HTML files with all styling, images, diagrams, and math embedded diff --git a/frontend/app.js b/frontend/app.js index 7fced06..ae22d18 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -857,6 +857,31 @@ function noteApp() { this.autoSave(); }, + /** + * Enter: continue blockquote / bullet / task list; second Enter on empty item exits (see editor-markdown-continue.js). + */ + handleEditorEnterKey(event) { + if (typeof EditorMarkdownContinue === 'undefined') return; + const textarea = event.target; + if (!textarea || textarea.id !== 'note-editor') return; + + const result = EditorMarkdownContinue.tryEnter( + this.noteContent, + textarea.selectionStart, + textarea.selectionEnd, + event + ); + if (!result.handled) return; + + event.preventDefault(); + this.noteContent = result.text; + this.$nextTick(() => { + textarea.selectionStart = textarea.selectionEnd = result.cursor; + }); + this.autoSave(); + this.updateSyntaxHighlight(); + }, + // Sort mode configuration sortModes: ['a-z', 'z-a', 'newest', 'oldest', 'largest', 'smallest'], sortModeIcons: { diff --git a/frontend/editor-markdown-continue.js b/frontend/editor-markdown-continue.js new file mode 100644 index 0000000..eac57c5 --- /dev/null +++ b/frontend/editor-markdown-continue.js @@ -0,0 +1,124 @@ +/** + * Markdown editor helpers: Enter continues blockquotes, bullets, and task lists. + * Plain functions only; no DOM. Used by app.js handleEditorEnterKey. + */ +(function (global) { + 'use strict'; + + /** + * True if cursor position lies inside a ``` fenced code block (toggle per line). + */ + function cursorInMarkdownCodeFence(text, pos) { + const before = text.slice(0, pos); + let lineStart = 0; + let inFence = false; + for (;;) { + const nl = before.indexOf('\n', lineStart); + const end = nl === -1 ? before.length : nl; + const line = before.slice(lineStart, end); + const trimmed = line.trimStart(); + if (trimmed.startsWith('```')) { + inFence = !inFence; + } + if (nl === -1) break; + lineStart = nl + 1; + } + return inFence; + } + + function getLineBounds(text, pos) { + const lineStart = text.lastIndexOf('\n', Math.max(0, pos - 1)) + 1; + let lineEnd = text.indexOf('\n', pos); + if (lineEnd === -1) lineEnd = text.length; + return { + lineStart, + lineEnd, + line: text.slice(lineStart, lineEnd), + }; + } + + /** + * @returns {{ continuePrefix: string, isEmpty: boolean } | null} + */ + function parseContinuationContext(line) { + const quoteMatch = line.match(/^(\s*)((?:>\s*)+)/); + let quoteFull = ''; + let rest = line; + if (quoteMatch) { + quoteFull = quoteMatch[1] + quoteMatch[2]; + rest = line.slice(quoteMatch[0].length); + } + + const taskMatch = rest.match(/^(\s*)([-*+])\s+\[([xX ])\]\s*(.*)$/); + if (taskMatch) { + const inner = taskMatch[1]; + const ch = taskMatch[2]; + const mark = taskMatch[3]; + const cont = taskMatch[4]; + const marker = ch + ' [' + mark + '] '; + const isEmpty = cont.trim() === ''; + return { continuePrefix: quoteFull + inner + marker, isEmpty }; + } + + const bulletMatch = rest.match(/^(\s*)([-*+])\s+(.*)$/); + if (bulletMatch) { + const inner = bulletMatch[1]; + const ch = bulletMatch[2]; + const cont = bulletMatch[3]; + const marker = ch + ' '; + const isEmpty = cont.trim() === ''; + return { continuePrefix: quoteFull + inner + marker, isEmpty }; + } + + if (quoteFull) { + const isEmpty = rest.trim() === ''; + return { continuePrefix: quoteFull, isEmpty }; + } + + return null; + } + + /** + * @param {string} text + * @param {number} selStart + * @param {number} selEnd + * @param {KeyboardEvent} event + * @returns {{ handled: false } | { handled: true, text: string, cursor: number }} + */ + function tryEnter(text, selStart, selEnd, event) { + if (event.key !== 'Enter') return { handled: false }; + if (event.defaultPrevented) return { handled: false }; + if (event.isComposing) return { handled: false }; + if (event.shiftKey) return { handled: false }; + if (event.ctrlKey || event.metaKey || event.altKey) return { handled: false }; + if (selStart !== selEnd) return { handled: false }; + if (cursorInMarkdownCodeFence(text, selStart)) return { handled: false }; + + const { lineStart, lineEnd, line } = getLineBounds(text, selStart); + if (selStart !== lineEnd) return { handled: false }; + + const lineForParse = line.replace(/\r/g, ''); + const ctx = parseContinuationContext(lineForParse); + if (!ctx) return { handled: false }; + + if (ctx.isEmpty) { + let delEnd = lineEnd; + if (delEnd < text.length && text.charAt(delEnd) === '\n') { + delEnd += 1; + } + const newText = text.slice(0, lineStart) + text.slice(delEnd); + return { handled: true, text: newText, cursor: lineStart }; + } + + const insert = '\n' + ctx.continuePrefix; + const newText = text.slice(0, lineEnd) + insert + text.slice(lineEnd); + return { handled: true, text: newText, cursor: lineEnd + insert.length }; + } + + global.EditorMarkdownContinue = { + tryEnter, + parseContinuationContext, + getLineBounds, + cursorInMarkdownCodeFence, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/index.html b/frontend/index.html index 1e7aa1f..609f662 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2682,6 +2682,7 @@ x-model="noteContent" @input="autoSave(); updateSyntaxHighlight()" @scroll="syncOverlayScroll()" + @keydown.enter="handleEditorEnterKey($event)" @keydown.tab="handleTabKey($event)" @drop="onEditorDrop($event)" @dragover.prevent="onEditorDragOver($event)" @@ -3433,6 +3434,7 @@ + From e3afede989997a72176d46bc0039cf557fa86555 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 13 Apr 2026 16:11:55 +0200 Subject: [PATCH 2/5] fix for media path traversal --- frontend/app.js | 63 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index ae22d18..26ff8cc 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -193,6 +193,7 @@ function noteApp() { // Preview rendering debounce _previewDebounceTimeout: null, _lastRenderedContent: '', + _lastRenderedNote: '', _cachedRenderedHTML: '', _mathDebounceTimeout: null, _mermaidDebounceTimeout: null, @@ -1287,6 +1288,49 @@ function noteApp() { return this._mediaLookup.get(nameLower) || null; }, + // Resolve a Markdown image path to a vault-relative path (forward slashes). + // Relative paths use the note file's directory as base (same rules as CommonMark / browsers). + // A leading "/" (not "//") means vault-root-relative. + resolveMarkdownMediaPathForNote(noteVaultPath, rawSrc) { + const pathPart = rawSrc.split('#')[0].split('?')[0]; + if (!pathPart) return ''; + if (pathPart.startsWith('/') && !pathPart.startsWith('//')) { + return pathPart.replace(/^\/+/, ''); + } + if (!noteVaultPath) { + return pathPart; + } + const dir = noteVaultPath.includes('/') + ? noteVaultPath.slice(0, noteVaultPath.lastIndexOf('/')) + : ''; + const base = `https://vn.invalid/${dir ? `${dir}/` : ''}`; + try { + const u = new URL(pathPart, base); + let p = u.pathname; + if (p.startsWith('/')) p = p.slice(1); + return p.split('/').filter(seg => seg !== '').map(seg => { + try { + return decodeURIComponent(seg); + } catch (e) { + return seg; + } + }).join('/'); + } catch (e) { + return pathPart; + } + }, + + encodeVaultRelativePathForMediaApi(vaultRelativePath) { + if (!vaultRelativePath) return ''; + return vaultRelativePath.split('/').map(segment => { + try { + return encodeURIComponent(decodeURIComponent(segment)); + } catch (e) { + return encodeURIComponent(segment); + } + }).join('/'); + }, + // Load all tags async loadTags() { try { @@ -2858,6 +2902,7 @@ function noteApp() { this.currentNote = notePath; this._lastRenderedContent = ''; // Clear render cache for new note + this._lastRenderedNote = ''; this._cachedRenderedHTML = ''; this._initializedVideoSources = new Set(); // Clear video cache for new note this.noteContent = data.content; @@ -3962,6 +4007,7 @@ function noteApp() { this.noteContent = ''; this.currentNoteName = ''; this._lastRenderedContent = ''; // Clear render cache + this._lastRenderedNote = ''; this._cachedRenderedHTML = ''; document.title = this.appName; // Redirect to root @@ -4133,7 +4179,9 @@ function noteApp() { if (!this.noteContent) return '

Nothing to preview yet...

'; // Performance: Return cached HTML if content hasn't changed - if (this.noteContent === this._lastRenderedContent && this._cachedRenderedHTML) { + if (this.noteContent === this._lastRenderedContent && + this.currentNote === this._lastRenderedNote && + this._cachedRenderedHTML) { return this._cachedRenderedHTML; } @@ -4327,14 +4375,10 @@ function noteApp() { // Transform relative paths to /api/media/ for serving if (isLocal && !src.startsWith('/api/media/')) { - // URL-encode path segments to handle spaces and special characters - const encodedPath = src.split('/').map(segment => { - try { - return encodeURIComponent(decodeURIComponent(segment)); - } catch (e) { - return encodeURIComponent(segment); - } - }).join('/'); + const vaultRelative = self.currentNote + ? self.resolveMarkdownMediaPathForNote(self.currentNote, src) + : src.split('#')[0].split('?')[0]; + const encodedPath = self.encodeVaultRelativePathForMediaApi(vaultRelative); src = `/api/media/${encodedPath}`; img.setAttribute('src', src); } @@ -4434,6 +4478,7 @@ function noteApp() { // Cache the result for performance this._lastRenderedContent = this.noteContent; + this._lastRenderedNote = this.currentNote; this._cachedRenderedHTML = html; return html; From e7894c3712a1647a04affc9306d575d5f2c1ef7f Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 13 Apr 2026 16:15:03 +0200 Subject: [PATCH 3/5] fix for metadata note retrieval --- backend/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/utils.py b/backend/utils.py index 1292165..26629e0 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -526,7 +526,11 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict: """Get metadata for a note""" full_path = Path(notes_dir) / note_path - if not full_path.exists(): + if not full_path.exists() or not full_path.is_file(): + return {} + + # Security check: ensure the path is within notes_dir (same as get_note_content) + if not validate_path_security(notes_dir, full_path): return {} stat = full_path.stat() From aa06901a534587c1d65db28e18a8835472a42ac2 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 13 Apr 2026 16:18:36 +0200 Subject: [PATCH 4/5] doc fix --- documentation/FEATURES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 7e53e1e..aa194aa 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -23,6 +23,7 @@ - **Documents** - PDF (default max 20MB, configurable) - **In-app viewing** - View all media types directly in the sidebar - **Inline preview** - Audio/video players and PDF viewer embedded in notes +- **Relative media paths** - For `![alt](path)`, paths resolve from the note’s folder ### Organization - **Folder hierarchy** - Organize notes in nested folders From a474e15554b27b70fec52be7860ece633f8f9632 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 13 Apr 2026 16:23:34 +0200 Subject: [PATCH 5/5] updated localization for sorting tooltips --- locales/de-DE.json | 8 ++++---- locales/en-GB.json | 8 ++++---- locales/en-US.json | 8 ++++---- locales/es-ES.json | 8 ++++---- locales/fr-FR.json | 8 ++++---- locales/hu-HU.json | 8 ++++---- locales/it-IT.json | 8 ++++---- locales/ja-JP.json | 8 ++++---- locales/ru-RU.json | 8 ++++---- locales/sl-SI.json | 8 ++++---- locales/zh-CN.json | 8 ++++---- 11 files changed, 44 insertions(+), 44 deletions(-) diff --git a/locales/de-DE.json b/locales/de-DE.json index 3db797d..8665d22 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -51,10 +51,10 @@ "collapse_all": "Alle Ordner zuklappen", "sort_a-z": "Sortieren A-Z", "sort_z-a": "Sortieren Z-A", - "sort_newest": "Nach neuesten sortieren", - "sort_oldest": "Nach ältesten sortieren", - "sort_largest": "Nach größten sortieren", - "sort_smallest": "Nach kleinsten sortieren", + "sort_newest": "Nach neuesten sortieren (nur Dateien)", + "sort_oldest": "Nach ältesten sortieren (nur Dateien)", + "sort_largest": "Nach größten sortieren (nur Dateien)", + "sort_smallest": "Nach kleinsten sortieren (nur Dateien)", "toggle_sidebar": "Seitenleiste umschalten", "go_to_homepage": "Zur Startseite", "files": "Dateien", diff --git a/locales/en-GB.json b/locales/en-GB.json index d599288..41c5eb6 100644 --- a/locales/en-GB.json +++ b/locales/en-GB.json @@ -50,10 +50,10 @@ "collapse_all": "Collapse all folders", "sort_a-z": "Sort A to Z", "sort_z-a": "Sort Z to A", - "sort_newest": "Sort by newest", - "sort_oldest": "Sort by oldest", - "sort_largest": "Sort by largest", - "sort_smallest": "Sort by smallest", + "sort_newest": "Sort by newest (files only)", + "sort_oldest": "Sort by oldest (files only)", + "sort_largest": "Sort by largest (files only)", + "sort_smallest": "Sort by smallest (files only)", "toggle_sidebar": "Toggle sidebar", "go_to_homepage": "Go to homepage", "files": "Files", diff --git a/locales/en-US.json b/locales/en-US.json index 709e926..d3abe0c 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -51,10 +51,10 @@ "collapse_all": "Collapse all folders", "sort_a-z": "Sort A to Z", "sort_z-a": "Sort Z to A", - "sort_newest": "Sort by newest", - "sort_oldest": "Sort by oldest", - "sort_largest": "Sort by largest", - "sort_smallest": "Sort by smallest", + "sort_newest": "Sort by newest (files only)", + "sort_oldest": "Sort by oldest (files only)", + "sort_largest": "Sort by largest (files only)", + "sort_smallest": "Sort by smallest (files only)", "toggle_sidebar": "Toggle sidebar", "go_to_homepage": "Go to homepage", "files": "Files", diff --git a/locales/es-ES.json b/locales/es-ES.json index c6a846b..79bf391 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -51,10 +51,10 @@ "collapse_all": "Contraer todas las carpetas", "sort_a-z": "Ordenar A-Z", "sort_z-a": "Ordenar Z-A", - "sort_newest": "Ordenar por más reciente", - "sort_oldest": "Ordenar por más antiguo", - "sort_largest": "Ordenar por más grande", - "sort_smallest": "Ordenar por más pequeño", + "sort_newest": "Ordenar por más reciente (solo archivos)", + "sort_oldest": "Ordenar por más antiguo (solo archivos)", + "sort_largest": "Ordenar por más grande (solo archivos)", + "sort_smallest": "Ordenar por más pequeño (solo archivos)", "toggle_sidebar": "Alternar barra lateral", "go_to_homepage": "Ir al inicio", "files": "Archivos", diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 90548db..22e6972 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -51,10 +51,10 @@ "collapse_all": "Réduire tous les dossiers", "sort_a-z": "Trier A-Z", "sort_z-a": "Trier Z-A", - "sort_newest": "Trier par plus récent", - "sort_oldest": "Trier par plus ancien", - "sort_largest": "Trier par plus grand", - "sort_smallest": "Trier par plus petit", + "sort_newest": "Trier par plus récent (fichiers uniquement)", + "sort_oldest": "Trier par plus ancien (fichiers uniquement)", + "sort_largest": "Trier par plus grand (fichiers uniquement)", + "sort_smallest": "Trier par plus petit (fichiers uniquement)", "toggle_sidebar": "Afficher/Masquer la barre latérale", "go_to_homepage": "Aller à l'accueil", "files": "Fichiers", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 86b3fe4..1c75857 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -51,10 +51,10 @@ "collapse_all": "Mappák összecsukása", "sort_a-z": "Rendezés A-Z", "sort_z-a": "Rendezés Z-A", - "sort_newest": "Legújabb elöl", - "sort_oldest": "Legrégebbi elöl", - "sort_largest": "Legnagyobb elöl", - "sort_smallest": "Legkisebb elöl", + "sort_newest": "Legújabb elöl (csak fájlok)", + "sort_oldest": "Legrégebbi elöl (csak fájlok)", + "sort_largest": "Legnagyobb elöl (csak fájlok)", + "sort_smallest": "Legkisebb elöl (csak fájlok)", "toggle_sidebar": "Oldalsáv megjelenítése", "go_to_homepage": "Ugrás a kezdőlapra", "files": "Fájlok", diff --git a/locales/it-IT.json b/locales/it-IT.json index ae2a91a..88a3d0a 100644 --- a/locales/it-IT.json +++ b/locales/it-IT.json @@ -50,10 +50,10 @@ "collapse_all": "Comprimi tutte le cartelle", "sort_a-z": "Ordina A-Z", "sort_z-a": "Ordina Z-A", - "sort_newest": "Ordina per più recente", - "sort_oldest": "Ordina per più vecchio", - "sort_largest": "Ordina per più grande", - "sort_smallest": "Ordina per più piccolo", + "sort_newest": "Ordina per più recente (solo file)", + "sort_oldest": "Ordina per più vecchio (solo file)", + "sort_largest": "Ordina per più grande (solo file)", + "sort_smallest": "Ordina per più piccolo (solo file)", "toggle_sidebar": "Mostra/nascondi barra laterale", "go_to_homepage": "Vai alla home", "files": "File", diff --git a/locales/ja-JP.json b/locales/ja-JP.json index 2c9c752..93e25d3 100644 --- a/locales/ja-JP.json +++ b/locales/ja-JP.json @@ -50,10 +50,10 @@ "collapse_all": "すべて折りたたむ", "sort_a-z": "A-Z順", "sort_z-a": "Z-A順", - "sort_newest": "新しい順", - "sort_oldest": "古い順", - "sort_largest": "大きい順", - "sort_smallest": "小さい順", + "sort_newest": "新しい順(ファイルのみ)", + "sort_oldest": "古い順(ファイルのみ)", + "sort_largest": "大きい順(ファイルのみ)", + "sort_smallest": "小さい順(ファイルのみ)", "toggle_sidebar": "サイドバー切替", "go_to_homepage": "ホームへ", "files": "ファイル", diff --git a/locales/ru-RU.json b/locales/ru-RU.json index ed4645f..30edffc 100644 --- a/locales/ru-RU.json +++ b/locales/ru-RU.json @@ -50,10 +50,10 @@ "collapse_all": "Свернуть все папки", "sort_a-z": "Сортировка А-Я", "sort_z-a": "Сортировка Я-А", - "sort_newest": "Сначала новые", - "sort_oldest": "Сначала старые", - "sort_largest": "Сначала большие", - "sort_smallest": "Сначала маленькие", + "sort_newest": "Сначала новые (только файлы)", + "sort_oldest": "Сначала старые (только файлы)", + "sort_largest": "Сначала большие (только файлы)", + "sort_smallest": "Сначала маленькие (только файлы)", "toggle_sidebar": "Показать/скрыть боковую панель", "go_to_homepage": "На главную", "files": "Файлы", diff --git a/locales/sl-SI.json b/locales/sl-SI.json index f7e36e6..8d4ffd4 100644 --- a/locales/sl-SI.json +++ b/locales/sl-SI.json @@ -50,10 +50,10 @@ "collapse_all": "Strni vse mape", "sort_a-z": "Razvrsti A-Ž", "sort_z-a": "Razvrsti Ž-A", - "sort_newest": "Razvrsti po najnovejših", - "sort_oldest": "Razvrsti po najstarejših", - "sort_largest": "Razvrsti po največjih", - "sort_smallest": "Razvrsti po najmanjših", + "sort_newest": "Razvrsti po najnovejših (samo datoteke)", + "sort_oldest": "Razvrsti po najstarejših (samo datoteke)", + "sort_largest": "Razvrsti po največjih (samo datoteke)", + "sort_smallest": "Razvrsti po najmanjših (samo datoteke)", "toggle_sidebar": "Preklopi stransko vrstico", "go_to_homepage": "Pojdi na začetno stran", "files": "Datoteke", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index c680380..ee977d7 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -50,10 +50,10 @@ "collapse_all": "折叠所有文件夹", "sort_a-z": "按A-Z排序", "sort_z-a": "按Z-A排序", - "sort_newest": "按最新排序", - "sort_oldest": "按最旧排序", - "sort_largest": "按最大排序", - "sort_smallest": "按最小排序", + "sort_newest": "按最新排序(仅文件)", + "sort_oldest": "按最旧排序(仅文件)", + "sort_largest": "按最大排序(仅文件)", + "sort_smallest": "按最小排序(仅文件)", "toggle_sidebar": "切换侧边栏", "go_to_homepage": "前往主页", "files": "文件",