From cb6725f11c007293c0aabf12aeb7dbe8bfea54a5 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 15:22:09 +0200 Subject: [PATCH 01/15] added drawing support --- backend/export.py | 2 +- backend/main.py | 52 ++++- backend/utils.py | 7 +- frontend/app.js | 550 ++++++++++++++++++++++++++++++++++++++++---- frontend/index.html | 131 ++++++++++- locales/de-DE.json | 13 ++ locales/en-GB.json | 13 ++ locales/en-US.json | 13 ++ locales/es-ES.json | 13 ++ locales/fr-FR.json | 13 ++ locales/hu-HU.json | 13 ++ locales/it-IT.json | 13 ++ locales/ja-JP.json | 13 ++ locales/ru-RU.json | 13 ++ locales/sl-SI.json | 13 ++ locales/zh-CN.json | 13 ++ 16 files changed, 829 insertions(+), 56 deletions(-) diff --git a/backend/export.py b/backend/export.py index 0a0bd8f..4feb441 100644 --- a/backend/export.py +++ b/backend/export.py @@ -49,7 +49,7 @@ def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]: def get_image_as_base64(image_path: Path) -> Optional[str]: """Read an image file and return it as a base64 data URL.""" result = get_media_as_base64(image_path) - if result and result[1] == 'image': + if result and result[1] in ('image', 'drawing'): return result[0] return None diff --git a/backend/main.py b/backend/main.py index 6499f8b..bbb0b2a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -625,9 +625,59 @@ async def get_media(media_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file")) +@api_router.put("/media/{media_path:path}", tags=["Media"]) +@limiter.limit("30/minute") +async def put_media(media_path: str, request: Request): + """ + Overwrite an existing media file in place (used for saving drawing PNGs). + Only files named drawing-*.png are accepted to avoid accidental overwrites. + """ + try: + from backend.utils import ALL_MEDIA_EXTENSIONS + + notes_dir = config['storage']['notes_dir'] + full_path = Path(notes_dir) / media_path + + if not validate_path_security(notes_dir, full_path): + raise HTTPException(status_code=403, detail="Access denied") + + name_lower = full_path.name.lower() + if not (name_lower.startswith('drawing-') and name_lower.endswith('.png')): + raise HTTPException( + status_code=400, + detail="Only drawing PNG files (drawing-*.png) can be updated in place", + ) + + if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not an allowed media file") + + if not full_path.exists() or not full_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + body = await request.body() + max_size = UPLOAD_MAX_IMAGE_MB * 1024 * 1024 + if len(body) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: {UPLOAD_MAX_IMAGE_MB}MB", + ) + + try: + with open(full_path, 'wb') as f: + f.write(body) + except OSError as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save file")) + + return {"success": True, "path": media_path} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to update media file")) + + @api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): +async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form("")): """ Upload a media file (image, audio, video, PDF) and save it to the attachments directory. Returns the relative path for markdown linking. diff --git a/backend/utils.py b/backend/utils.py index 26629e0..e639c93 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -671,8 +671,13 @@ ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values()) def get_media_type(filename: str) -> Optional[str]: """ Determine the media type based on file extension. - Returns: 'image', 'audio', 'video', 'document', or None if not a media file. + Returns: 'image', 'audio', 'video', 'document', 'drawing', or None if not a media file. + + Drawings are PNG files stored like images but named drawing-*.png (editable canvas in the app). """ + name_lower = Path(filename).name.lower() + if name_lower.startswith('drawing-') and name_lower.endswith('.png'): + return 'drawing' ext = Path(filename).suffix.lower() for media_type, extensions in MEDIA_EXTENSIONS.items(): if ext in extensions: diff --git a/frontend/app.js b/frontend/app.js index d171759..b27d0b1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -494,7 +494,16 @@ function noteApp() { // Media viewer state currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility) - currentMediaType: 'image', // 'image', 'audio', 'video', 'document' + currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing' + + // Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG + drawingTool: 'freehand', + drawingColor: '#1a1a1a', + drawingLineWidth: 4, + drawingOps: [], + drawingRedoStack: [], + drawingDraft: null, + drawingIsPointerDown: false, // DOM element cache (to avoid repeated querySelector calls) _domCache: { @@ -638,10 +647,15 @@ function noteApp() { window.addEventListener('keydown', (e) => { // Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts - // Ctrl/Cmd + S to save + // Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown) if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { - e.preventDefault(); - this.saveNote(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingSave(); + } else { + e.preventDefault(); + this.saveNote(); + } } // Ctrl/Cmd + Alt + P for Quick Switcher @@ -663,21 +677,35 @@ function noteApp() { this.createFolder(); } - // Ctrl/Cmd + Z for undo (without shift or alt) - // Use e.key instead of e.code to support non-QWERTY keyboard layouts + // Ctrl/Cmd + Z for undo (drawing vs note editor) if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - e.preventDefault(); - this.undo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingUndo(); + } else { + e.preventDefault(); + this.undo(); + } } // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { - e.preventDefault(); - this.redo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingRedo(); + } else { + e.preventDefault(); + this.redo(); + } } if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - e.preventDefault(); - this.redo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingRedo(); + } else { + e.preventDefault(); + this.redo(); + } } // F3 for next search match @@ -1484,13 +1512,7 @@ function noteApp() { notePath += '.md'; } - // Determine target folder: use dropdown context if set, otherwise homepage folder - let targetFolder; - if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { - targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path - } else { - targetFolder = this.selectedHomepageFolder || ''; - } + const targetFolder = this.inferredNewItemTargetFolder(); // If we have a target folder, create note in that folder if (targetFolder) { @@ -2082,7 +2104,7 @@ function noteApp() { }, handleDeleteItemClick(el, event) { event.stopPropagation(); - if (el.dataset.type === 'image') { + if (el.dataset.type !== 'note') { this.deleteMedia(el.dataset.path); } else { this.deleteNote(el.dataset.path, el.dataset.name); @@ -2208,6 +2230,11 @@ function noteApp() { const isShared = !isMediaFile && this.isNoteShared(note.path); const shareIcon = isShared ? '' : ''; const icon = this.getMediaIcon(note.type); + const deleteTitle = !isMediaFile + ? this.t('toolbar.delete_note') + : note.type === 'drawing' + ? this.t('toolbar.delete_drawing') + : this.t('toolbar.delete_image'); return `
@@ -2487,13 +2514,22 @@ function noteApp() { this.draggedItem = null; }, + /** + * Backend `get_attachment_dir` uses the parent folder of `note_path`. + * With no note open, infer a synthetic path from `currentMedia` so uploads go to the same + * `_attachments` folder as the file being viewed (or vault root when appropriate). + */ + resolveUploadNotePath() { + if (this.currentNote) return this.currentNote; + if (!this.currentMedia) return ''; + const parts = this.currentMedia.split('/').filter(Boolean); + const ai = parts.indexOf('_attachments'); + if (ai === -1 || ai === 0) return ''; + return `${parts.slice(0, ai).join('/')}/_.md`; + }, + // Handle media files dropped into editor async handleMediaDrop(event) { - if (!this.currentNote) { - this.toast(this.t('notes.open_first'), { type: 'info' }); - return; - } - const files = Array.from(event.dataTransfer.files); // Filter for allowed media types @@ -2515,28 +2551,38 @@ function noteApp() { } const textarea = event.target; - // Calculate cursor position from drop coordinates - let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); - if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; + const notePath = this.resolveUploadNotePath(); + // Calculate cursor position from drop coordinates (only meaningful when a note is open) + let cursorPos = 0; + if (this.currentNote && textarea && textarea.tagName === 'TEXTAREA') { + cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); + if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; + } - // Upload each media file + let uploaded = false; for (const file of mediaFiles) { try { - const mediaPath = await this.uploadMedia(file, this.currentNote); + const mediaPath = await this.uploadMedia(file, notePath); if (mediaPath) { - await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); + uploaded = true; + if (this.currentNote) { + await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); + } } } catch (error) { ErrorHandler.handle(`upload file ${file.name}`, error); } } + if (uploaded && !this.currentNote) { + await this.loadNotes(); + } }, // Upload a media file (image, audio, video, PDF) async uploadMedia(file, notePath) { const formData = new FormData(); formData.append('file', file); - formData.append('note_path', notePath); + formData.append('note_path', notePath || ''); try { const response = await fetch('/api/upload-media', { @@ -2621,9 +2667,13 @@ function noteApp() { } }, - // Media type detection based on file extension + // Media type detection based on file extension (and drawing-*.png convention) getMediaType(filename) { if (!filename) return null; + const base = filename.split('/').pop().toLowerCase(); + if (base.startsWith('drawing-') && base.endsWith('.png')) { + return 'drawing'; + } const ext = filename.split('.').pop().toLowerCase(); const mediaTypes = { image: ['jpg', 'jpeg', 'png', 'gif', 'webp'], @@ -2641,6 +2691,7 @@ function noteApp() { getMediaIcon(type) { const icons = { image: '🖼️', + drawing: '✏️', audio: '🎵', video: '🎬', document: '📄', @@ -2662,6 +2713,9 @@ function noteApp() { // View a media file (image, audio, video, PDF) in the main pane viewMedia(mediaPath, mediaType = null, updateHistory = true) { + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.showGraph = false; // Ensure graph is closed this.currentNote = ''; this.currentNoteName = ''; @@ -2688,6 +2742,14 @@ function noteApp() { `/${encodedPath}` ); } + + // Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing + // to another keeps currentMediaType === 'drawing', so we must reload the PNG here. + if (this.currentMediaType === 'drawing') { + this.$nextTick(() => { + this.initDrawingViewer(); + }); + } }, // Backward compatibility alias @@ -2723,6 +2785,399 @@ function noteApp() { } }, + /** + * Create a blank drawing PNG and open it for editing. + * Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder). + */ + async createNewDrawing() { + const targetFolder = this.inferredNewItemTargetFolder(); + this.closeDropdown(); + const w = 1200; + const h = 675; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, w, h); + let blob; + try { + blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); + }); + } catch (e) { + ErrorHandler.handle('create drawing', e); + return; + } + const file = new File([blob], 'drawing.png', { type: 'image/png' }); + try { + const notePath = targetFolder ? `${targetFolder}/_.md` : ''; + const path = await this.uploadMedia(file, notePath); + await this.loadNotes(); + this.viewMedia(path, 'drawing'); + } catch (error) { + ErrorHandler.handle('upload drawing', error); + } + }, + + _drawingEncodeMediaPath() { + return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/'); + }, + + _drawingCanvasCoords(event) { + const canvas = this._drawingCanvasEl; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const scaleX = this._drawingCssW / rect.width; + const scaleY = this._drawingCssH / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; + }, + + _drawingDrawOp(ctx, op) { + if (!op) return; + if (op.type === 'stroke') { + const pts = op.points; + if (!pts || pts.length < 2) return; + ctx.save(); + ctx.strokeStyle = op.color; + ctx.lineWidth = op.lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) { + ctx.lineTo(pts[i][0], pts[i][1]); + } + ctx.stroke(); + ctx.restore(); + return; + } + ctx.save(); + ctx.strokeStyle = op.color; + ctx.lineWidth = op.lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + if (op.type === 'line') { + ctx.beginPath(); + ctx.moveTo(op.x1, op.y1); + ctx.lineTo(op.x2, op.y2); + ctx.stroke(); + } else if (op.type === 'rect') { + const nx = op.w < 0 ? op.x + op.w : op.x; + const ny = op.h < 0 ? op.y + op.h : op.y; + ctx.strokeRect(nx, ny, Math.abs(op.w), Math.abs(op.h)); + } else if (op.type === 'ellipse') { + const nx = op.w < 0 ? op.x + op.w : op.x; + const ny = op.h < 0 ? op.y + op.h : op.y; + const rw = Math.abs(op.w) / 2; + const rh = Math.abs(op.h) / 2; + const cx = nx + rw; + const cy = ny + rh; + ctx.beginPath(); + ctx.ellipse(cx, cy, rw, rh, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + }, + + drawingRedraw() { + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + const w = this._drawingCssW; + const h = this._drawingCssH; + const dpr = this._drawingDpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, w, h); + if (this._drawingBaseImage && this._drawingBaseImage.complete) { + ctx.drawImage(this._drawingBaseImage, 0, 0, w, h); + } + for (const op of this.drawingOps) { + this._drawingDrawOp(ctx, op); + } + if (this.drawingDraft) { + this._drawingDrawOp(ctx, this.drawingDraft); + } + }, + + _drawingScheduleRedraw() { + if (this._drawingRaf) return; + this._drawingRaf = requestAnimationFrame(() => { + this._drawingRaf = null; + this.drawingRedraw(); + }); + }, + + _drawingDisconnectResizeObserver() { + if (this._drawingResizeObserver) { + try { + this._drawingResizeObserver.disconnect(); + } catch (_) { + /* ignore */ + } + this._drawingResizeObserver = null; + } + }, + + /** Size canvas to drawingCanvasWrap and redraw (fills available pane). */ + _drawingLayoutCanvas() { + const wrap = this.$refs.drawingCanvasWrap; + const canvas = this.$refs.drawingCanvas; + if (!wrap || !canvas || this.currentMediaType !== 'drawing') return; + let cssW = wrap.clientWidth; + let cssH = wrap.clientHeight; + if (cssW < 32) cssW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320); + if (cssH < 32) { + const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1'); + const h = col ? col.clientHeight : 0; + cssH = h > 64 ? h : Math.max(240, Math.floor((cssW || 400) * 0.5)); + } + const dpr = window.devicePixelRatio || 1; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + this._drawingCanvasEl = canvas; + this._drawingCssW = cssW; + this._drawingCssH = cssH; + this._drawingDpr = dpr; + if (!this._drawingCtx) { + this._drawingCtx = canvas.getContext('2d'); + } + this._drawingCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.drawingRedraw(); + }, + + async initDrawingViewer() { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + this._drawingDisconnectResizeObserver(); + await this.$nextTick(); + if (this._drawingObjectURL) { + URL.revokeObjectURL(this._drawingObjectURL); + this._drawingObjectURL = null; + } + const canvas = this.$refs.drawingCanvas; + const wrap = this.$refs.drawingCanvasWrap; + if (!canvas || !wrap) return; + this._drawingCtx = canvas.getContext('2d'); + this.drawingOps = []; + this.drawingRedoStack = []; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this._drawingBaseImage = null; + this._drawingLoadToken = Symbol(); + const token = this._drawingLoadToken; + + this._drawingResizeObserver = new ResizeObserver(() => { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + this._drawingLayoutCanvas(); + }); + this._drawingResizeObserver.observe(wrap); + + requestAnimationFrame(() => { + if (token !== this._drawingLoadToken) return; + this._drawingLayoutCanvas(); + requestAnimationFrame(() => { + if (token !== this._drawingLoadToken) return; + this._drawingLayoutCanvas(); + }); + }); + + try { + const enc = this._drawingEncodeMediaPath(); + const res = await fetch(`/api/media/${enc}`, { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Failed to load drawing'); + const blob = await res.blob(); + if (token !== this._drawingLoadToken) return; + const url = URL.createObjectURL(blob); + this._drawingObjectURL = url; + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = url; + }); + if (token !== this._drawingLoadToken) return; + this._drawingBaseImage = img; + this._drawingLayoutCanvas(); + } catch (e) { + if (token !== this._drawingLoadToken) return; + ErrorHandler.handle('load drawing', e); + this._drawingLayoutCanvas(); + } + }, + + drawingPointerDown(e) { + if (this.currentMediaType !== 'drawing' || e.button !== 0) return; + const canvas = this._drawingCanvasEl; + if (!canvas) return; + canvas.setPointerCapture(e.pointerId); + this._drawingPointerId = e.pointerId; + const { x, y } = this._drawingCanvasCoords(e); + this.drawingIsPointerDown = true; + this.drawingRedoStack = []; + const color = this.drawingColor; + const lw = this.drawingLineWidth; + const tool = this.drawingTool; + if (tool === 'freehand') { + this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] }; + } else if (tool === 'line') { + this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y }; + } else if (tool === 'rect') { + this.drawingDraft = { type: 'rect', color, lineWidth: lw, x, y, w: 0, h: 0 }; + } else if (tool === 'ellipse') { + this.drawingDraft = { type: 'ellipse', color, lineWidth: lw, x, y, w: 0, h: 0 }; + } + this.drawingRedraw(); + }, + + drawingPointerMove(e) { + if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; + const { x, y } = this._drawingCanvasCoords(e); + const d = this.drawingDraft; + if (!d) return; + if (d.type === 'stroke') { + const pts = d.points; + const last = pts[pts.length - 1]; + const dx = x - last[0]; + const dy = y - last[1]; + if (dx * dx + dy * dy < 1) return; + pts.push([x, y]); + this._drawingScheduleRedraw(); + return; + } + if (d.type === 'line') { + d.x2 = x; + d.y2 = y; + } else if (d.type === 'rect' || d.type === 'ellipse') { + d.w = x - d.x; + d.h = y - d.y; + } + this._drawingScheduleRedraw(); + }, + + drawingPointerUp(e) { + if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; + const canvas = this._drawingCanvasEl; + if (canvas && this._drawingPointerId === e.pointerId) { + try { + canvas.releasePointerCapture(e.pointerId); + } catch (_) { + /* ignore */ + } + } + this._drawingPointerId = null; + this.drawingIsPointerDown = false; + const d = this.drawingDraft; + this.drawingDraft = null; + if (!d) { + this.drawingRedraw(); + return; + } + if (d.type === 'stroke') { + if (!d.points || d.points.length < 2) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: 'stroke', + color: d.color, + lineWidth: d.lineWidth, + points: d.points.slice(), + }); + } else if (d.type === 'line') { + const dx = d.x2 - d.x1; + const dy = d.y2 - d.y1; + if (dx * dx + dy * dy < 4) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: 'line', + color: d.color, + lineWidth: d.lineWidth, + x1: d.x1, + y1: d.y1, + x2: d.x2, + y2: d.y2, + }); + } else if (d.type === 'rect' || d.type === 'ellipse') { + if (Math.abs(d.w) < 2 && Math.abs(d.h) < 2) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: d.type, + color: d.color, + lineWidth: d.lineWidth, + x: d.x, + y: d.y, + w: d.w, + h: d.h, + }); + } + this.drawingRedraw(); + }, + + drawingUndo() { + if (this.drawingOps.length === 0) return; + this.drawingRedoStack.push(this.drawingOps.pop()); + this.drawingRedraw(); + }, + + drawingRedo() { + if (this.drawingRedoStack.length === 0) return; + this.drawingOps.push(this.drawingRedoStack.pop()); + this.drawingRedraw(); + }, + + async drawingSave() { + if (!this.currentMedia || this.currentMediaType !== 'drawing') return; + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this.drawingRedraw(); + await new Promise((r) => requestAnimationFrame(r)); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); + }); + try { + const enc = this._drawingEncodeMediaPath(); + const res = await fetch(`/api/media/${enc}`, { + method: 'PUT', + body: blob, + headers: { 'Content-Type': 'image/png' }, + credentials: 'same-origin', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + let detail = err.detail; + if (Array.isArray(detail)) { + detail = detail.map((d) => d.msg || d).join(', '); + } + throw new Error(detail || res.statusText); + } + await this.loadNotes(); + if (this._drawingObjectURL) { + URL.revokeObjectURL(this._drawingObjectURL); + this._drawingObjectURL = null; + } + this.drawingOps = []; + this.drawingRedoStack = []; + await this.initDrawingViewer(); + this.toast(this.t('drawing.saved'), { type: 'success' }); + } catch (error) { + ErrorHandler.handle('save drawing', error); + } + }, + // Handle clicks on internal links in preview handleInternalLink(event) { // Check if clicked element is a link @@ -2981,6 +3436,9 @@ function noteApp() { window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); this.currentNote = ''; this.noteContent = ''; + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.currentMedia = ''; document.title = this.appName; return; @@ -2997,6 +3455,9 @@ function noteApp() { this._initializedVideoSources = new Set(); // Clear video cache for new note this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.currentMedia = ''; // Clear image viewer when loading a note this.shareInfo = null; // Reset share info for new note @@ -3353,6 +3814,17 @@ function noteApp() { this.dropdownTargetFolder = null; // Reset folder context }, + /** + * Parent folder for new note/folder/drawing from the + menu when no explicit path is passed. + * Same rules as the create-name modal: '' = vault root; otherwise a folder path (e.g. folder1/sub). + */ + inferredNewItemTargetFolder() { + if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { + return this.dropdownTargetFolder; + } + return this.selectedHomepageFolder || ''; + }, + // ===================================================== // UNIFIED CREATION FUNCTIONS (reusable from anywhere) // ===================================================== @@ -3391,14 +3863,8 @@ function noteApp() { * @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root) */ openCreateNameModal(kind, explicitTargetFolder = undefined) { - let targetFolder; - if (explicitTargetFolder !== undefined) { - targetFolder = explicitTargetFolder; - } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { - targetFolder = this.dropdownTargetFolder; - } else { - targetFolder = this.selectedHomepageFolder || ''; - } + const targetFolder = + explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder(); this.closeDropdown(); this.mobileSidebarOpen = false; this.createNameModalKind = kind; diff --git a/frontend/index.html b/frontend/index.html index 71924ef..4cbb8b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2408,7 +2408,7 @@ style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='var(--bg-secondary)'" - :title="note.type !== 'note' ? t('toolbar.delete_image') : t('toolbar.delete_note')" + :title="note.type === 'note' ? t('toolbar.delete_note') : (note.type === 'drawing' ? t('toolbar.delete_drawing') : t('toolbar.delete_image'))" > @@ -2467,12 +2467,12 @@ class="text-xl font-semibold border-none focus:outline-none focus:ring-2 rounded px-3 py-1.5" :style="'background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); min-width: 150px; max-width: 300px;' + (!currentNote ? ' cursor: default; opacity: 0.9;' : '')" > - +
+ +
+ + + + +
+ + + +
+
@@ -2875,8 +2949,9 @@
@@ -3115,6 +3214,16 @@ 📄 + diff --git a/locales/de-DE.json b/locales/de-DE.json index b2310bc..974befa 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -42,6 +42,7 @@ "new_note": "Neue Notiz", "new_folder": "Neuer Ordner", "new_from_template": "Neu aus Vorlage", + "new_drawing": "Neue Zeichnung", "rename_folder": "Ordner umbenennen", "delete_folder": "Ordner löschen", "search_placeholder": "Notizen durchsuchen...", @@ -124,6 +125,7 @@ "redo": "Wiederholen (Strg+Y)", "delete_note": "Notiz löschen", "delete_image": "Bild löschen", + "delete_drawing": "Zeichnung löschen", "export_html": "Als HTML exportieren", "print_preview": "Druckvorschau", "copy_link": "Link in Zwischenablage kopieren", @@ -226,6 +228,17 @@ "error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut." }, + "drawing": { + "tool_freehand": "Stift — Freihand", + "tool_line": "Gerade Linie", + "tool_rect": "Rechteck", + "tool_ellipse": "Kreis", + "color": "Farbe", + "width": "Strichstärke", + "saved": "Zeichnung gespeichert", + "save_hint": "Zeichnung speichern (Strg+S)" + }, + "media": { "confirm_delete": "\"{{name}}\" löschen?", "upload_failed": "Datei-Upload fehlgeschlagen", diff --git a/locales/en-GB.json b/locales/en-GB.json index 52054f6..2ddaf96 100644 --- a/locales/en-GB.json +++ b/locales/en-GB.json @@ -42,6 +42,7 @@ "new_note": "New Note", "new_folder": "New Folder", "new_from_template": "New from Template", + "new_drawing": "New Drawing", "rename_folder": "Rename folder", "delete_folder": "Delete folder", "search_placeholder": "Search notes...", @@ -123,6 +124,7 @@ "redo": "Redo (Ctrl+Y)", "delete_note": "Delete note", "delete_image": "Delete image", + "delete_drawing": "Delete drawing", "export_html": "Export as HTML", "print_preview": "Print preview", "copy_link": "Copy link to clipboard", @@ -225,6 +227,17 @@ "error_incorrect_password": "Incorrect password. Please try again." }, + "drawing": { + "tool_freehand": "Pencil — freehand drawing", + "tool_line": "Straight line", + "tool_rect": "Rectangle", + "tool_ellipse": "Circle", + "color": "Colour", + "width": "Stroke width", + "saved": "Drawing saved", + "save_hint": "Save drawing (Ctrl+S)" + }, + "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", diff --git a/locales/en-US.json b/locales/en-US.json index af4614b..92fd810 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -42,6 +42,7 @@ "new_note": "New Note", "new_folder": "New Folder", "new_from_template": "New from Template", + "new_drawing": "New Drawing", "rename_folder": "Rename folder", "delete_folder": "Delete folder", "search_placeholder": "Search notes...", @@ -124,6 +125,7 @@ "redo": "Redo (Ctrl+Y)", "delete_note": "Delete note", "delete_image": "Delete image", + "delete_drawing": "Delete drawing", "export_html": "Export as HTML", "print_preview": "Print preview", "copy_link": "Copy link to clipboard", @@ -226,6 +228,17 @@ "error_incorrect_password": "Incorrect password. Please try again." }, + "drawing": { + "tool_freehand": "Pencil — freehand drawing", + "tool_line": "Straight line", + "tool_rect": "Rectangle", + "tool_ellipse": "Circle", + "color": "Color", + "width": "Stroke width", + "saved": "Drawing saved", + "save_hint": "Save drawing (Ctrl+S)" + }, + "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", diff --git a/locales/es-ES.json b/locales/es-ES.json index a4a9f2e..199cf26 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -42,6 +42,7 @@ "new_note": "Nueva Nota", "new_folder": "Nueva Carpeta", "new_from_template": "Nueva desde Plantilla", + "new_drawing": "Nuevo dibujo", "rename_folder": "Renombrar carpeta", "delete_folder": "Eliminar carpeta", "search_placeholder": "Buscar notas...", @@ -124,6 +125,7 @@ "redo": "Rehacer (Ctrl+Y)", "delete_note": "Eliminar nota", "delete_image": "Eliminar imagen", + "delete_drawing": "Eliminar dibujo", "export_html": "Exportar como HTML", "print_preview": "Vista previa de impresión", "copy_link": "Copiar enlace al portapapeles", @@ -226,6 +228,17 @@ "error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo." }, + "drawing": { + "tool_freehand": "Lápiz — trazo libre", + "tool_line": "Línea recta", + "tool_rect": "Rectángulo", + "tool_ellipse": "Círculo", + "color": "Color", + "width": "Grosor del trazo", + "saved": "Dibujo guardado", + "save_hint": "Guardar dibujo (Ctrl+S)" + }, + "media": { "confirm_delete": "¿Eliminar \"{{name}}\"?", "upload_failed": "Error al subir archivo", diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 5f3e08d..0de70c2 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -42,6 +42,7 @@ "new_note": "Nouvelle Note", "new_folder": "Nouveau Dossier", "new_from_template": "Nouveau depuis Modèle", + "new_drawing": "Nouveau dessin", "rename_folder": "Renommer le dossier", "delete_folder": "Supprimer le dossier", "search_placeholder": "Rechercher des notes...", @@ -124,6 +125,7 @@ "redo": "Rétablir (Ctrl+Y)", "delete_note": "Supprimer la note", "delete_image": "Supprimer l'image", + "delete_drawing": "Supprimer le dessin", "export_html": "Exporter en HTML", "print_preview": "Aperçu avant impression", "copy_link": "Copier le lien dans le presse-papiers", @@ -226,6 +228,17 @@ "error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer." }, + "drawing": { + "tool_freehand": "Crayon — main levée", + "tool_line": "Ligne droite", + "tool_rect": "Rectangle", + "tool_ellipse": "Cercle", + "color": "Couleur", + "width": "Épaisseur du trait", + "saved": "Dessin enregistré", + "save_hint": "Enregistrer le dessin (Ctrl+S)" + }, + "media": { "confirm_delete": "Supprimer \"{{name}}\" ?", "upload_failed": "Échec du téléchargement du fichier", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 298d945..63aa67c 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -42,6 +42,7 @@ "new_note": "Új Jegyzet", "new_folder": "Új Mappa", "new_from_template": "Új Sablonból", + "new_drawing": "Új rajz", "rename_folder": "Mappa átnevezése", "delete_folder": "Mappa törlése", "search_placeholder": "Jegyzetek keresése...", @@ -124,6 +125,7 @@ "redo": "Helyrehoz (Ctrl+Y)", "delete_note": "Jegyzet törlése", "delete_image": "Kép törlése", + "delete_drawing": "Rajz törlése", "export_html": "Exportálás HTML-ként", "print_preview": "Nyomtatási előnézet", "copy_link": "Link másolása", @@ -226,6 +228,17 @@ "error_incorrect_password": "Helytelen jelszó. Próbáld újra." }, + "drawing": { + "tool_freehand": "Ceruza — szabadkézi", + "tool_line": "Egyenes vonal", + "tool_rect": "Téglalap", + "tool_ellipse": "Kör", + "color": "Szín", + "width": "Vonalvastagság", + "saved": "Rajz elmentve", + "save_hint": "Rajz mentése (Ctrl+S)" + }, + "media": { "confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?", "upload_failed": "Hiba a fájl feltöltése során", diff --git a/locales/it-IT.json b/locales/it-IT.json index c6c6e43..c216a2b 100644 --- a/locales/it-IT.json +++ b/locales/it-IT.json @@ -42,6 +42,7 @@ "new_note": "Nuova Nota", "new_folder": "Nuova Cartella", "new_from_template": "Nuovo da Modello", + "new_drawing": "Nuovo disegno", "rename_folder": "Rinomina cartella", "delete_folder": "Elimina cartella", "search_placeholder": "Cerca note...", @@ -123,6 +124,7 @@ "redo": "Ripeti (Ctrl+Y)", "delete_note": "Elimina nota", "delete_image": "Elimina immagine", + "delete_drawing": "Elimina disegno", "export_html": "Esporta come HTML", "print_preview": "Anteprima di stampa", "copy_link": "Copia link negli appunti", @@ -225,6 +227,17 @@ "error_incorrect_password": "Password errata. Riprova." }, + "drawing": { + "tool_freehand": "Matita — mano libera", + "tool_line": "Linea retta", + "tool_rect": "Rettangolo", + "tool_ellipse": "Cerchio", + "color": "Colore", + "width": "Spessore tratto", + "saved": "Disegno salvato", + "save_hint": "Salva disegno (Ctrl+S)" + }, + "media": { "confirm_delete": "Eliminare \"{{name}}\"?", "upload_failed": "Caricamento file fallito", diff --git a/locales/ja-JP.json b/locales/ja-JP.json index 93067c7..2932ab2 100644 --- a/locales/ja-JP.json +++ b/locales/ja-JP.json @@ -42,6 +42,7 @@ "new_note": "新規ノート", "new_folder": "新規フォルダ", "new_from_template": "テンプレートから作成", + "new_drawing": "新規お絵かき", "rename_folder": "フォルダの名前を変更", "delete_folder": "フォルダを削除", "search_placeholder": "ノートを検索...", @@ -123,6 +124,7 @@ "redo": "やり直す (Ctrl+Y)", "delete_note": "ノートを削除", "delete_image": "画像を削除", + "delete_drawing": "お絵かきを削除", "export_html": "HTMLとしてエクスポート", "print_preview": "印刷プレビュー", "copy_link": "リンクをクリップボードにコピー", @@ -225,6 +227,17 @@ "error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。" }, + "drawing": { + "tool_freehand": "鉛筆(フリーハンド)", + "tool_line": "直線", + "tool_rect": "長方形", + "tool_ellipse": "円", + "color": "色", + "width": "線の太さ", + "saved": "保存しました", + "save_hint": "お絵かきを保存(Ctrl+S)" + }, + "media": { "confirm_delete": "「{{name}}」を削除しますか?", "upload_failed": "ファイルのアップロードに失敗しました", diff --git a/locales/ru-RU.json b/locales/ru-RU.json index 14690b1..dd1685e 100644 --- a/locales/ru-RU.json +++ b/locales/ru-RU.json @@ -42,6 +42,7 @@ "new_note": "Новая заметка", "new_folder": "Новая папка", "new_from_template": "Из шаблона", + "new_drawing": "Новый рисунок", "rename_folder": "Переименовать папку", "delete_folder": "Удалить папку", "search_placeholder": "Поиск заметок...", @@ -123,6 +124,7 @@ "redo": "Повторить (Ctrl+Y)", "delete_note": "Удалить заметку", "delete_image": "Удалить изображение", + "delete_drawing": "Удалить рисунок", "export_html": "Экспорт в HTML", "print_preview": "Предварительный просмотр печати", "copy_link": "Копировать ссылку", @@ -225,6 +227,17 @@ "error_incorrect_password": "Неверный пароль. Попробуйте снова." }, + "drawing": { + "tool_freehand": "Карандаш — от руки", + "tool_line": "Прямая линия", + "tool_rect": "Прямоугольник", + "tool_ellipse": "Круг", + "color": "Цвет", + "width": "Толщина линии", + "saved": "Рисунок сохранён", + "save_hint": "Сохранить рисунок (Ctrl+S)" + }, + "media": { "confirm_delete": "Удалить «{{name}}»?", "upload_failed": "Не удалось загрузить файл", diff --git a/locales/sl-SI.json b/locales/sl-SI.json index b80d9b7..b219d6b 100644 --- a/locales/sl-SI.json +++ b/locales/sl-SI.json @@ -42,6 +42,7 @@ "new_note": "Nov zapis", "new_folder": "Nova mapa", "new_from_template": "Novo iz predloge", + "new_drawing": "Nov risba", "rename_folder": "Preimenuj mapo", "delete_folder": "Izbriši mapo", "search_placeholder": "Išči zapiske...", @@ -123,6 +124,7 @@ "redo": "Ponovi (Ctrl+Y)", "delete_note": "Izbriši zapis", "delete_image": "Izbriši sliko", + "delete_drawing": "Izbriši risbo", "export_html": "Izvozi kot HTML", "print_preview": "Predogled tiskanja", "copy_link": "Kopiraj povezavo v odložišče", @@ -225,6 +227,17 @@ "error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova." }, + "drawing": { + "tool_freehand": "Svinčnik — prostoročno", + "tool_line": "Ravna črta", + "tool_rect": "Pravokotnik", + "tool_ellipse": "Krog", + "color": "Barva", + "width": "Debelina črte", + "saved": "Risba shranjena", + "save_hint": "Shrani risbo (Ctrl+S)" + }, + "media": { "confirm_delete": "Izbrišem \"{{name}}\"?", "upload_failed": "Nalaganje datoteke ni uspelo", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 981cc76..0d603f0 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -42,6 +42,7 @@ "new_note": "新建笔记", "new_folder": "新建文件夹", "new_from_template": "从模板新建", + "new_drawing": "新建绘图", "rename_folder": "重命名文件夹", "delete_folder": "删除文件夹", "search_placeholder": "搜索笔记...", @@ -123,6 +124,7 @@ "redo": "重做 (Ctrl+Y)", "delete_note": "删除笔记", "delete_image": "删除图片", + "delete_drawing": "删除绘图", "export_html": "导出为 HTML", "print_preview": "打印预览", "copy_link": "复制链接到剪贴板", @@ -225,6 +227,17 @@ "error_incorrect_password": "密码错误。请重试。" }, + "drawing": { + "tool_freehand": "铅笔 — 自由绘制", + "tool_line": "直线", + "tool_rect": "矩形", + "tool_ellipse": "圆形", + "color": "颜色", + "width": "线条粗细", + "saved": "绘图已保存", + "save_hint": "保存绘图(Ctrl+S)" + }, + "media": { "confirm_delete": "删除 \"{{name}}\"?", "upload_failed": "上传文件失败", From ef82c277c01da46af69ad1a590482087c88301f2 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 16:00:58 +0200 Subject: [PATCH 02/15] changed target folder, fixed bugs --- backend/main.py | 37 ++++++++++++++++++++-- backend/utils.py | 81 ++++++++++++++++++++++++++++-------------------- frontend/app.js | 78 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/backend/main.py b/backend/main.py index bbb0b2a..cff914b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -677,9 +677,16 @@ async def put_media(media_path: str, request: Request): @api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form("")): +async def upload_media( + request: Request, + file: UploadFile = File(...), + note_path: str = Form(""), + content_folder: str = Form(""), + next_to_notes: str = Form(""), +): """ - Upload a media file (image, audio, video, PDF) and save it to the attachments directory. + Upload a media file (image, audio, video, PDF) and save it to the attachments directory, + or (when next_to_notes=1) save a new drawing PNG next to markdown notes in content_folder. Returns the relative path for markdown linking. """ try: @@ -727,6 +734,32 @@ async def upload_media(request: Request, file: UploadFile = File(...), note_path detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" ) + if (next_to_notes or "").strip() == "1": + is_png = file.content_type in ("image/png",) or (file.filename and file.filename.lower().endswith(".png")) + if not is_png: + raise HTTPException( + status_code=400, + detail="next_to_notes requires a PNG file", + ) + file_path = save_uploaded_image( + config["storage"]["notes_dir"], + "", + file.filename or "drawing.png", + file_data, + sibling_folder=content_folder or "", + ) + if not file_path: + raise HTTPException(status_code=500, detail="Failed to save drawing") + out_name = Path(file_path).name + media_type = get_media_type(out_name) or "drawing" + return { + "success": True, + "path": file_path, + "filename": out_name, + "type": media_type, + "message": "Drawing created", + } + # Save the file (reusing image save function - it works for any file) file_path = save_uploaded_image( config['storage']['notes_dir'], diff --git a/backend/utils.py b/backend/utils.py index e639c93..06a37ac 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -604,54 +604,67 @@ def get_attachment_dir(notes_dir: str, note_path: str) -> Path: return Path(notes_dir) / folder / "_attachments" -def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]: +def save_uploaded_image( + notes_dir: str, + note_path: str, + filename: str, + file_data: bytes, + *, + sibling_folder: Optional[str] = None, +) -> Optional[str]: """ - Save an uploaded image to the appropriate attachments directory. - Returns the relative path to the image if successful, None otherwise. - - Args: - notes_dir: Base notes directory - note_path: Path of the note the image is being uploaded to - filename: Original filename - file_data: Binary file data - - Returns: - Relative path to the saved image, or None if failed + Save uploaded media under the vault. + + Default (sibling_folder is None): store in ``_attachments`` next to the note implied by + ``note_path`` (drag/drop, paste, etc.). + + If ``sibling_folder`` is set (including ``""`` for vault root): store ``drawing-{timestamp}.png`` + in that folder next to ``.md`` files — used for new drawings from the + menu. + + Returns a relative path from ``notes_dir``, or None on failure. """ - # Sanitize filename + base = Path(notes_dir) + + if sibling_folder is not None: + cf = (sibling_folder or "").strip().replace("\\", "/") + segments = [p for p in cf.split("/") if p and p != "."] + if any(p == ".." for p in segments): + return None + dest_dir = base.joinpath(*segments) if segments else base + if not validate_path_security(notes_dir, dest_dir / ".nd_probe"): + return None + try: + dest_dir.mkdir(parents=True, exist_ok=True) + except OSError: + return None + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + full_path = dest_dir / f"drawing-{timestamp}.png" + if not validate_path_security(notes_dir, full_path): + return None + try: + with open(full_path, "wb") as f: + f.write(file_data) + return str(full_path.relative_to(base).as_posix()) + except OSError as e: + print(f"Error saving image: {e}") + return None + sanitized_name = sanitize_filename(filename) - - # Get extension ext = Path(sanitized_name).suffix name_without_ext = Path(sanitized_name).stem - - # Add timestamp to prevent collisions - timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") final_filename = f"{name_without_ext}-{timestamp}{ext}" - - # Get attachments directory attachments_dir = get_attachment_dir(notes_dir, note_path) - - # Create directory if it doesn't exist attachments_dir.mkdir(parents=True, exist_ok=True) - - # Full path to save the image full_path = attachments_dir / final_filename - - # Security check if not validate_path_security(notes_dir, full_path): print(f"Security: Attempted to save image outside notes directory: {full_path}") return None - try: - # Write the file - with open(full_path, 'wb') as f: + with open(full_path, "wb") as f: f.write(file_data) - - # Return relative path from notes_dir - relative_path = full_path.relative_to(Path(notes_dir)) - return str(relative_path.as_posix()) - except Exception as e: + return str(full_path.relative_to(base).as_posix()) + except OSError as e: print(f"Error saving image: {e}") return None diff --git a/frontend/app.js b/frontend/app.js index b27d0b1..d818efb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,7 +2,7 @@ // Configuration constants const CONFIG = { - AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave + AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave) SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference @@ -504,6 +504,7 @@ function noteApp() { drawingRedoStack: [], drawingDraft: null, drawingIsPointerDown: false, + _drawingAutosaveTimeout: null, // DOM element cache (to avoid repeated querySelector calls) _domCache: { @@ -2579,10 +2580,18 @@ function noteApp() { }, // Upload a media file (image, audio, video, PDF) - async uploadMedia(file, notePath) { + // Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments). + async uploadMedia(file, notePath, options = {}) { + const { nextToNotes, contentFolder } = options; const formData = new FormData(); formData.append('file', file); - formData.append('note_path', notePath || ''); + if (nextToNotes) { + formData.append('next_to_notes', '1'); + formData.append('content_folder', contentFolder != null ? contentFolder : ''); + formData.append('note_path', ''); + } else { + formData.append('note_path', notePath || ''); + } try { const response = await fetch('/api/upload-media', { @@ -2715,6 +2724,7 @@ function noteApp() { viewMedia(mediaPath, mediaType = null, updateHistory = true) { if (this.currentMediaType === 'drawing') { this._drawingDisconnectResizeObserver(); + this._drawingCancelAutosave(); } this.showGraph = false; // Ensure graph is closed this.currentNote = ''; @@ -2811,12 +2821,14 @@ function noteApp() { } const file = new File([blob], 'drawing.png', { type: 'image/png' }); try { - const notePath = targetFolder ? `${targetFolder}/_.md` : ''; - const path = await this.uploadMedia(file, notePath); + const path = await this.uploadMedia(file, '', { + nextToNotes: true, + contentFolder: targetFolder, + }); await this.loadNotes(); this.viewMedia(path, 'drawing'); } catch (error) { - ErrorHandler.handle('upload drawing', error); + ErrorHandler.handle('create drawing', error); } }, @@ -2924,6 +2936,40 @@ function noteApp() { } }, + _drawingCancelAutosave() { + if (this._drawingAutosaveTimeout) { + clearTimeout(this._drawingAutosaveTimeout); + this._drawingAutosaveTimeout = null; + } + }, + + /** + * Debounced PNG save (same delay as notes). Never runs while the primary button is held + * (active stroke/shape); pending timers are cleared when a new stroke starts. + */ + _drawingScheduleAutosave() { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + if (this._drawingAutosaveTimeout) { + clearTimeout(this._drawingAutosaveTimeout); + this._drawingAutosaveTimeout = null; + } + const path = this.currentMedia; + const pollMs = 150; + const attemptSave = () => { + if (this.currentMedia !== path || this.currentMediaType !== 'drawing') { + this._drawingAutosaveTimeout = null; + return; + } + if (this.drawingIsPointerDown) { + this._drawingAutosaveTimeout = setTimeout(attemptSave, pollMs); + return; + } + this._drawingAutosaveTimeout = null; + this.drawingSave({ silent: true }); + }; + this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY); + }, + /** Size canvas to drawingCanvasWrap and redraw (fills available pane). */ _drawingLayoutCanvas() { const wrap = this.$refs.drawingCanvasWrap; @@ -3016,6 +3062,7 @@ function noteApp() { if (this.currentMediaType !== 'drawing' || e.button !== 0) return; const canvas = this._drawingCanvasEl; if (!canvas) return; + this._drawingCancelAutosave(); canvas.setPointerCapture(e.pointerId); this._drawingPointerId = e.pointerId; const { x, y } = this._drawingCanvasCoords(e); @@ -3122,22 +3169,27 @@ function noteApp() { }); } this.drawingRedraw(); + this._drawingScheduleAutosave(); }, drawingUndo() { if (this.drawingOps.length === 0) return; this.drawingRedoStack.push(this.drawingOps.pop()); this.drawingRedraw(); + this._drawingScheduleAutosave(); }, drawingRedo() { if (this.drawingRedoStack.length === 0) return; this.drawingOps.push(this.drawingRedoStack.pop()); this.drawingRedraw(); + this._drawingScheduleAutosave(); }, - async drawingSave() { + async drawingSave(options = {}) { + const { silent = false } = options; if (!this.currentMedia || this.currentMediaType !== 'drawing') return; + this._drawingCancelAutosave(); const canvas = this._drawingCanvasEl; const ctx = this._drawingCtx; if (!canvas || !ctx) return; @@ -3148,6 +3200,7 @@ function noteApp() { const blob = await new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); }); + this.isSaving = true; try { const enc = this._drawingEncodeMediaPath(); const res = await fetch(`/api/media/${enc}`, { @@ -3172,9 +3225,18 @@ function noteApp() { this.drawingOps = []; this.drawingRedoStack = []; await this.initDrawingViewer(); - this.toast(this.t('drawing.saved'), { type: 'success' }); + if (!silent) { + this.toast(this.t('drawing.saved'), { type: 'success' }); + } else { + this.lastSaved = true; + setTimeout(() => { + this.lastSaved = false; + }, CONFIG.SAVE_INDICATOR_DURATION); + } } catch (error) { ErrorHandler.handle('save drawing', error); + } finally { + this.isSaving = false; } }, From 5f98ed3d5363f73ec0ba7a10a711714edb38ae04 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 16:04:39 +0200 Subject: [PATCH 03/15] don't clear undo stack on drawing save --- frontend/app.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index d818efb..e39f847 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3218,14 +3218,16 @@ function noteApp() { throw new Error(detail || res.statusText); } await this.loadNotes(); - if (this._drawingObjectURL) { - URL.revokeObjectURL(this._drawingObjectURL); - this._drawingObjectURL = null; - } - this.drawingOps = []; - this.drawingRedoStack = []; - await this.initDrawingViewer(); + // Manual save: flatten to PNG on disk and reset canvas state from file (clears stroke undo/redo). + // Autosave: only persist the PNG; keep drawingOps / redo stacks like note undo after save. if (!silent) { + if (this._drawingObjectURL) { + URL.revokeObjectURL(this._drawingObjectURL); + this._drawingObjectURL = null; + } + this.drawingOps = []; + this.drawingRedoStack = []; + await this.initDrawingViewer(); this.toast(this.t('drawing.saved'), { type: 'success' }); } else { this.lastSaved = true; From c83454ae2b7b7648471e955f1842eca2b1e06f58 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 16:34:34 +0200 Subject: [PATCH 04/15] save drawings with no toast --- frontend/app.js | 31 +++++++++++-------------------- locales/de-DE.json | 34 ---------------------------------- locales/en-GB.json | 34 ---------------------------------- locales/en-US.json | 34 ---------------------------------- locales/es-ES.json | 34 ---------------------------------- locales/fr-FR.json | 34 ---------------------------------- locales/hu-HU.json | 33 --------------------------------- locales/it-IT.json | 33 --------------------------------- locales/ja-JP.json | 33 --------------------------------- locales/ru-RU.json | 33 --------------------------------- locales/sl-SI.json | 35 +---------------------------------- locales/zh-CN.json | 34 ---------------------------------- 12 files changed, 12 insertions(+), 390 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index e39f847..39bf836 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2965,7 +2965,7 @@ function noteApp() { return; } this._drawingAutosaveTimeout = null; - this.drawingSave({ silent: true }); + this.drawingSave(); }; this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY); }, @@ -3186,8 +3186,12 @@ function noteApp() { this._drawingScheduleAutosave(); }, - async drawingSave(options = {}) { - const { silent = false } = options; + /** + * Persist the flattened canvas to disk (Ctrl+S, toolbar, autosave). Same feedback as saveNote: + * header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when + * opening another drawing via initDrawingViewer(). + */ + async drawingSave() { if (!this.currentMedia || this.currentMediaType !== 'drawing') return; this._drawingCancelAutosave(); const canvas = this._drawingCanvasEl; @@ -3218,23 +3222,10 @@ function noteApp() { throw new Error(detail || res.statusText); } await this.loadNotes(); - // Manual save: flatten to PNG on disk and reset canvas state from file (clears stroke undo/redo). - // Autosave: only persist the PNG; keep drawingOps / redo stacks like note undo after save. - if (!silent) { - if (this._drawingObjectURL) { - URL.revokeObjectURL(this._drawingObjectURL); - this._drawingObjectURL = null; - } - this.drawingOps = []; - this.drawingRedoStack = []; - await this.initDrawingViewer(); - this.toast(this.t('drawing.saved'), { type: 'success' }); - } else { - this.lastSaved = true; - setTimeout(() => { - this.lastSaved = false; - }, CONFIG.SAVE_INDICATOR_DURATION); - } + this.lastSaved = true; + setTimeout(() => { + this.lastSaved = false; + }, CONFIG.SAVE_INDICATOR_DURATION); } catch (error) { ErrorHandler.handle('save drawing', error); } finally { diff --git a/locales/de-DE.json b/locales/de-DE.json index 974befa..a6effb2 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -4,11 +4,9 @@ "name": "Deutsch", "flag": "🇩🇪" }, - "app": { "tagline": "Deine selbstgehostete Wissensdatenbank" }, - "common": { "save": "Speichern", "cancel": "Abbrechen", @@ -28,12 +26,10 @@ "copy_to_clipboard": "In Zwischenablage kopieren", "failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut." }, - "toast": { "region_label": "Benachrichtigungen", "dismiss": "Schließen" }, - "sidebar": { "title": "DATEIEN", "favorites_title": "Favoriten", @@ -73,7 +69,6 @@ "settings_title": "EINSTELLUNGEN", "filtered_notes": "Gefilterte Notizen" }, - "editor": { "placeholder": "Schreibe in Markdown...", "drop_hint": "💡 Hier ablegen, um an Cursorposition einzufügen...", @@ -86,7 +81,6 @@ "hours_ago": "vor {{count}}h", "days_ago": "vor {{count}}T" }, - "notes": { "confirm_delete": "\"{{name}}\" löschen?", "already_exists": "Eine Notiz mit dem Namen \"{{name}}\" existiert bereits an diesem Ort.\nBitte wähle einen anderen Namen.", @@ -102,7 +96,6 @@ "no_content": "Kein Inhalt zum Exportieren", "open_first": "Bitte öffne zuerst eine Notiz, bevor du Bilder hochlädst." }, - "folders": { "confirm_delete": "⚠️ WARNUNG ⚠️\n\nBist du sicher, dass du den Ordner \"{{name}}\" löschen möchtest?\n\nDies LÖSCHT DAUERHAFT:\n• Alle Notizen in diesem Ordner\n• Alle Unterordner und deren Inhalte\n\nDiese Aktion kann NICHT rückgängig gemacht werden!", "already_exists": "Ein Ordner mit dem Namen \"{{name}}\" existiert bereits an diesem Ort.\nBitte wähle einen anderen Namen.", @@ -119,7 +112,6 @@ "cannot_move_into_self": "Ordner kann nicht in sich selbst oder einen Unterordner verschoben werden.", "empty": "leer" }, - "toolbar": { "undo": "Rückgängig (Strg+Z)", "redo": "Wiederholen (Strg+Y)", @@ -132,14 +124,12 @@ "add_favorite": "Zu Favoriten hinzufügen", "remove_favorite": "Aus Favoriten entfernen" }, - "zen_mode": { "title": "Zen-Modus", "tooltip": "Zen-Modus (Strg+Alt+Z)", "exit": "Zen-Modus beenden", "exit_hint": "Zen-Modus beenden (Esc)" }, - "share": { "button_tooltip": "Notiz teilen", "modal_title": "Notiz teilen", @@ -154,7 +144,6 @@ "show_qr": "QR-Code anzeigen", "hide_qr": "QR-Code ausblenden" }, - "quick_switcher": { "placeholder": "Notizen suchen...", "no_results": "Keine Ergebnisse", @@ -163,7 +152,6 @@ "open": "öffnen", "close": "schließen" }, - "tags": { "title": "Tags", "no_tags": "Keine Tags gefunden", @@ -172,13 +160,11 @@ "clear_all": "Tag-Filter löschen", "filter_by": "Nach {{tag}} filtern ({{count}} Notizen)" }, - "outline": { "title": "Gliederung", "no_headings": "Keine Überschriften gefunden", "hint": "Überschriften mit # hinzufügen" }, - "backlinks": { "title": "Rückverweise", "no_backlinks": "Keine Rückverweise gefunden", @@ -186,21 +172,18 @@ "select_note": "Wählen Sie eine Notiz um Rückverweise zu sehen", "more_refs": "mehr" }, - "stats": { "words": "Wörter", "reading_time": "~{{minutes}} Min. Lesezeit", "links": "{{count}} Links", "click_details": "▼ Klicken für Details" }, - "graph": { "title": "Graphansicht", "empty": "Keine Verbindungen anzuzeigen", "markdown_links": "Markdown-Links", "click_hint": "Klick: auswählen • Doppelklick: öffnen" }, - "templates": { "title": "Vorlagen", "select": "Vorlage auswählen...", @@ -214,11 +197,9 @@ "available_placeholders": "Verfügbare Platzhalter", "create_note": "Notiz erstellen" }, - "export": { "failed": "HTML-Export fehlgeschlagen: {{error}}" }, - "login": { "title": "Anmelden", "tagline": "Deine selbstgehostete Wissensdatenbank", @@ -227,7 +208,6 @@ "footer": "🔒 Sicher & Selbstgehostet", "error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut." }, - "drawing": { "tool_freehand": "Stift — Freihand", "tool_line": "Gerade Linie", @@ -235,23 +215,19 @@ "tool_ellipse": "Kreis", "color": "Farbe", "width": "Strichstärke", - "saved": "Zeichnung gespeichert", "save_hint": "Zeichnung speichern (Strg+S)" }, - "media": { "confirm_delete": "\"{{name}}\" löschen?", "upload_failed": "Datei-Upload fehlgeschlagen", "no_valid_files": "Keine gültigen Dateien gefunden. Unterstützt: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Verschieben der Notiz fehlgeschlagen.", "failed_folder": "Verschieben des Ordners fehlgeschlagen.", "failed_media": "Verschieben der Mediendatei fehlgeschlagen." }, - "search": { "previous": "Vorheriger (Umschalt+F3)", "next": "Nächster (F3)", @@ -262,22 +238,18 @@ "no_results": "Keine Notizen enthalten \"{{query}}\"", "searching": "Suche nach \"{{query}}\"..." }, - "theme": { "title": "Design" }, - "language": { "title": "Sprache" }, - "syntax_highlight": { "title": "Syntax-Highlighting", "description": "Markdown-Syntax im Editor einfärben", "enable": "Syntax-Highlighting aktivieren", "disable": "Syntax-Highlighting deaktivieren" }, - "settings": { "account": "Konto", "logout": "Abmelden", @@ -288,7 +260,6 @@ "tab_inserts_tab": "Tab-Taste fügt Tab ein", "tab_inserts_tab_desc": "Tab drücken, um ein Tabulatorzeichen einzufügen statt den Fokus zu wechseln" }, - "homepage": { "title": "Startseite", "welcome": "Willkommen bei NoteDiscovery", @@ -301,7 +272,6 @@ "folder_singular": "Ordner", "folder_plural": "Ordner" }, - "format": { "bold": "Fett", "italic": "Kursiv", @@ -317,23 +287,19 @@ "checkbox": "Kontrollkästchen", "table": "Tabelle" }, - "validation": { "forbidden_chars": "Der Name enthält unzulässige Zeichen: {{chars}}", "reserved_name": "Dieser Name ist vom Betriebssystem reserviert.", "invalid_dot": "Der Name darf nicht nur ein Punkt sein.", "trailing_dot_space": "Der Name darf nicht mit einem Punkt oder Leerzeichen enden." }, - "support": { "enjoying_demo": "Unterstütze dieses Projekt", "deploy_own": "Eigene Instanz bereitstellen", "thank_you": "Danke für deine Unterstützung! 💚" }, - "demo": { "title": "DEMO-MODUS", "warning": "Inhalte werden täglich zurückgesetzt. Änderungen können von anderen Benutzern überschrieben werden." } } - diff --git a/locales/en-GB.json b/locales/en-GB.json index 2ddaf96..fbd6850 100644 --- a/locales/en-GB.json +++ b/locales/en-GB.json @@ -4,11 +4,9 @@ "name": "English", "flag": "🇬🇧" }, - "app": { "tagline": "Your Self-Hosted Knowledge Base" }, - "common": { "save": "Save", "cancel": "Cancel", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Copy to clipboard", "failed": "Failed to {{action}}. Please try again." }, - "toast": { "region_label": "Notifications", "dismiss": "Dismiss" }, - "sidebar": { "title": "FILES", "favorites_title": "Favourites", @@ -72,7 +68,6 @@ "settings_title": "SETTINGS", "filtered_notes": "Filtered Notes" }, - "editor": { "placeholder": "Start writing in markdown...", "drop_hint": "💡 Drop here to insert at cursor position...", @@ -85,7 +80,6 @@ "hours_ago": "{{count}}h ago", "days_ago": "{{count}}d ago" }, - "notes": { "confirm_delete": "Delete \"{{name}}\"?", "already_exists": "A note named \"{{name}}\" already exists in this location.\nPlease choose a different name.", @@ -101,7 +95,6 @@ "no_content": "No note content to export", "open_first": "Please open a note first before uploading images." }, - "folders": { "confirm_delete": "⚠️ WARNING ⚠️\n\nAre you sure you want to delete the folder \"{{name}}\"?\n\nThis will PERMANENTLY delete:\n• All notes inside this folder\n• All subfolders and their contents\n\nThis action CANNOT be undone!", "already_exists": "A folder named \"{{name}}\" already exists in this location.\nPlease choose a different name.", @@ -118,7 +111,6 @@ "cannot_move_into_self": "Cannot move folder into itself or its subfolder.", "empty": "empty" }, - "toolbar": { "undo": "Undo (Ctrl+Z)", "redo": "Redo (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "Add to favourites", "remove_favorite": "Remove from favourites" }, - "zen_mode": { "title": "Zen Mode", "tooltip": "Zen Mode (Ctrl+Alt+Z)", "exit": "Exit Zen Mode", "exit_hint": "Exit Zen Mode (Esc)" }, - "share": { "button_tooltip": "Share note", "modal_title": "Share Note", @@ -153,7 +143,6 @@ "show_qr": "Show QR Code", "hide_qr": "Hide QR Code" }, - "quick_switcher": { "placeholder": "Type to search notes...", "no_results": "No matching notes", @@ -162,7 +151,6 @@ "open": "open", "close": "close" }, - "tags": { "title": "Tags", "no_tags": "No tags found", @@ -171,13 +159,11 @@ "clear_all": "Clear tag filters", "filter_by": "Filter by {{tag}} ({{count}} notes)" }, - "outline": { "title": "Outline", "no_headings": "No headings found", "hint": "Add headings using # syntax" }, - "backlinks": { "title": "Backlinks", "no_backlinks": "No backlinks found", @@ -185,21 +171,18 @@ "select_note": "Select a note to see backlinks", "more_refs": "more" }, - "stats": { "words": "words", "reading_time": "~{{minutes}}m read", "links": "{{count}} links", "click_details": "▼ Click for details" }, - "graph": { "title": "Graph View", "empty": "No connections to display", "markdown_links": "Markdown links", "click_hint": "Click: select • Double-click: open" }, - "templates": { "title": "Templates", "select": "Select a template...", @@ -213,11 +196,9 @@ "available_placeholders": "Available placeholders", "create_note": "Create Note" }, - "export": { "failed": "Failed to export HTML: {{error}}" }, - "login": { "title": "Login", "tagline": "Your Self-Hosted Knowledge Base", @@ -226,7 +207,6 @@ "footer": "🔒 Secure & Self-Hosted", "error_incorrect_password": "Incorrect password. Please try again." }, - "drawing": { "tool_freehand": "Pencil — freehand drawing", "tool_line": "Straight line", @@ -234,23 +214,19 @@ "tool_ellipse": "Circle", "color": "Colour", "width": "Stroke width", - "saved": "Drawing saved", "save_hint": "Save drawing (Ctrl+S)" }, - "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", "no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Failed to move note.", "failed_folder": "Failed to move folder.", "failed_media": "Failed to move media file." }, - "search": { "previous": "Previous (Shift+F3)", "next": "Next (F3)", @@ -261,22 +237,18 @@ "no_results": "No notes contain \"{{query}}\"", "searching": "Searching for \"{{query}}\"..." }, - "theme": { "title": "Theme" }, - "language": { "title": "Language" }, - "syntax_highlight": { "title": "Editor Syntax Highlight", "description": "Colourise markdown syntax in editor", "enable": "Enable syntax highlighting", "disable": "Disable syntax highlighting" }, - "settings": { "account": "Account", "logout": "Logout", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tab key inserts tab", "tab_inserts_tab_desc": "Press Tab to insert a tab character instead of changing focus" }, - "homepage": { "title": "Home", "welcome": "Welcome to NoteDiscovery", @@ -300,7 +271,6 @@ "folder_singular": "folder", "folder_plural": "folders" }, - "format": { "bold": "Bold", "italic": "Italic", @@ -316,23 +286,19 @@ "checkbox": "Checkbox", "table": "Table" }, - "validation": { "forbidden_chars": "Name contains forbidden characters: {{chars}}", "reserved_name": "This name is reserved by the operating system.", "invalid_dot": "Name cannot be just a dot.", "trailing_dot_space": "Name cannot end with a dot or space." }, - "support": { "enjoying_demo": "Support this project", "deploy_own": "Deploy your own instance", "thank_you": "Thank you for your support! 💚" }, - "demo": { "title": "DEMO MODE", "warning": "Contents reset daily. Changes may be overwritten by other users." } } - diff --git a/locales/en-US.json b/locales/en-US.json index 92fd810..6f43fee 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -4,11 +4,9 @@ "name": "English", "flag": "🇺🇸" }, - "app": { "tagline": "Your Self-Hosted Knowledge Base" }, - "common": { "save": "Save", "cancel": "Cancel", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Copy to clipboard", "failed": "Failed to {{action}}. Please try again." }, - "toast": { "region_label": "Notifications", "dismiss": "Dismiss" }, - "sidebar": { "title": "FILES", "favorites_title": "Favorites", @@ -73,7 +69,6 @@ "settings_title": "SETTINGS", "filtered_notes": "Filtered Notes" }, - "editor": { "placeholder": "Start writing in markdown...", "drop_hint": "💡 Drop here to insert at cursor position...", @@ -86,7 +81,6 @@ "hours_ago": "{{count}}h ago", "days_ago": "{{count}}d ago" }, - "notes": { "confirm_delete": "Delete \"{{name}}\"?", "already_exists": "A note named \"{{name}}\" already exists in this location.\nPlease choose a different name.", @@ -102,7 +96,6 @@ "no_content": "No note content to export", "open_first": "Please open a note first before uploading images." }, - "folders": { "confirm_delete": "⚠️ WARNING ⚠️\n\nAre you sure you want to delete the folder \"{{name}}\"?\n\nThis will PERMANENTLY delete:\n• All notes inside this folder\n• All subfolders and their contents\n\nThis action CANNOT be undone!", "already_exists": "A folder named \"{{name}}\" already exists in this location.\nPlease choose a different name.", @@ -119,7 +112,6 @@ "cannot_move_into_self": "Cannot move folder into itself or its subfolder.", "empty": "empty" }, - "toolbar": { "undo": "Undo (Ctrl+Z)", "redo": "Redo (Ctrl+Y)", @@ -132,14 +124,12 @@ "add_favorite": "Add to favorites", "remove_favorite": "Remove from favorites" }, - "zen_mode": { "title": "Zen Mode", "tooltip": "Zen Mode (Ctrl+Alt+Z)", "exit": "Exit Zen Mode", "exit_hint": "Exit Zen Mode (Esc)" }, - "share": { "button_tooltip": "Share note", "modal_title": "Share Note", @@ -154,7 +144,6 @@ "show_qr": "Show QR Code", "hide_qr": "Hide QR Code" }, - "quick_switcher": { "placeholder": "Type to search notes...", "no_results": "No matching notes", @@ -163,7 +152,6 @@ "open": "open", "close": "close" }, - "tags": { "title": "Tags", "no_tags": "No tags found", @@ -172,13 +160,11 @@ "clear_all": "Clear tag filters", "filter_by": "Filter by {{tag}} ({{count}} notes)" }, - "outline": { "title": "Outline", "no_headings": "No headings found", "hint": "Add headings using # syntax" }, - "backlinks": { "title": "Backlinks", "no_backlinks": "No backlinks found", @@ -186,21 +172,18 @@ "select_note": "Select a note to see backlinks", "more_refs": "more" }, - "stats": { "words": "words", "reading_time": "~{{minutes}}m read", "links": "{{count}} links", "click_details": "▼ Click for details" }, - "graph": { "title": "Graph View", "empty": "No connections to display", "markdown_links": "Markdown links", "click_hint": "Click: select • Double-click: open" }, - "templates": { "title": "Templates", "select": "Select a template...", @@ -214,11 +197,9 @@ "available_placeholders": "Available placeholders", "create_note": "Create Note" }, - "export": { "failed": "Failed to export HTML: {{error}}" }, - "login": { "title": "Login", "tagline": "Your Self-Hosted Knowledge Base", @@ -227,7 +208,6 @@ "footer": "🔒 Secure & Self-Hosted", "error_incorrect_password": "Incorrect password. Please try again." }, - "drawing": { "tool_freehand": "Pencil — freehand drawing", "tool_line": "Straight line", @@ -235,23 +215,19 @@ "tool_ellipse": "Circle", "color": "Color", "width": "Stroke width", - "saved": "Drawing saved", "save_hint": "Save drawing (Ctrl+S)" }, - "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", "no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Failed to move note.", "failed_folder": "Failed to move folder.", "failed_media": "Failed to move media file." }, - "search": { "previous": "Previous (Shift+F3)", "next": "Next (F3)", @@ -262,22 +238,18 @@ "no_results": "No notes contain \"{{query}}\"", "searching": "Searching for \"{{query}}\"..." }, - "theme": { "title": "Theme" }, - "language": { "title": "Language" }, - "syntax_highlight": { "title": "Editor Syntax Highlight", "description": "Colorize markdown syntax in editor", "enable": "Enable syntax highlighting", "disable": "Disable syntax highlighting" }, - "settings": { "account": "Account", "logout": "Logout", @@ -288,7 +260,6 @@ "tab_inserts_tab": "Tab key inserts tab", "tab_inserts_tab_desc": "Press Tab to insert a tab character instead of changing focus" }, - "homepage": { "title": "Home", "welcome": "Welcome to NoteDiscovery", @@ -301,7 +272,6 @@ "folder_singular": "folder", "folder_plural": "folders" }, - "format": { "bold": "Bold", "italic": "Italic", @@ -317,23 +287,19 @@ "checkbox": "Checkbox", "table": "Table" }, - "validation": { "forbidden_chars": "Name contains forbidden characters: {{chars}}", "reserved_name": "This name is reserved by the operating system.", "invalid_dot": "Name cannot be just a dot.", "trailing_dot_space": "Name cannot end with a dot or space." }, - "support": { "enjoying_demo": "Support this project", "deploy_own": "Deploy your own instance", "thank_you": "Thank you for your support! 💚" }, - "demo": { "title": "DEMO MODE", "warning": "Contents reset daily. Changes may be overwritten by other users." } } - diff --git a/locales/es-ES.json b/locales/es-ES.json index 199cf26..a94cdaf 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -4,11 +4,9 @@ "name": "Español", "flag": "🇪🇸" }, - "app": { "tagline": "Tu Base de Conocimientos Autoalojada" }, - "common": { "save": "Guardar", "cancel": "Cancelar", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Copiar al portapapeles", "failed": "Error al {{action}}. Por favor, inténtalo de nuevo." }, - "toast": { "region_label": "Notificaciones", "dismiss": "Cerrar" }, - "sidebar": { "title": "ARCHIVOS", "favorites_title": "Favoritos", @@ -73,7 +69,6 @@ "settings_title": "CONFIGURACIÓN", "filtered_notes": "Notas Filtradas" }, - "editor": { "placeholder": "Empieza a escribir en markdown...", "drop_hint": "💡 Suelta aquí para insertar en la posición del cursor...", @@ -86,7 +81,6 @@ "hours_ago": "hace {{count}}h", "days_ago": "hace {{count}}d" }, - "notes": { "confirm_delete": "¿Eliminar \"{{name}}\"?", "already_exists": "Ya existe una nota llamada \"{{name}}\" en esta ubicación.\nPor favor, elige un nombre diferente.", @@ -102,7 +96,6 @@ "no_content": "No hay contenido para exportar", "open_first": "Por favor, abre una nota antes de subir imágenes." }, - "folders": { "confirm_delete": "⚠️ ADVERTENCIA ⚠️\n\n¿Estás seguro de que quieres eliminar la carpeta \"{{name}}\"?\n\nEsto eliminará PERMANENTEMENTE:\n• Todas las notas dentro de esta carpeta\n• Todas las subcarpetas y su contenido\n\n¡Esta acción NO se puede deshacer!", "already_exists": "Ya existe una carpeta llamada \"{{name}}\" en esta ubicación.\nPor favor, elige un nombre diferente.", @@ -119,7 +112,6 @@ "cannot_move_into_self": "No se puede mover una carpeta dentro de sí misma o de una subcarpeta.", "empty": "vacía" }, - "toolbar": { "undo": "Deshacer (Ctrl+Z)", "redo": "Rehacer (Ctrl+Y)", @@ -132,14 +124,12 @@ "add_favorite": "Añadir a favoritos", "remove_favorite": "Quitar de favoritos" }, - "zen_mode": { "title": "Modo Zen", "tooltip": "Modo Zen (Ctrl+Alt+Z)", "exit": "Salir del Modo Zen", "exit_hint": "Salir del Modo Zen (Esc)" }, - "share": { "button_tooltip": "Compartir nota", "modal_title": "Compartir Nota", @@ -154,7 +144,6 @@ "show_qr": "Mostrar código QR", "hide_qr": "Ocultar código QR" }, - "quick_switcher": { "placeholder": "Escribe para buscar notas...", "no_results": "Sin resultados", @@ -163,7 +152,6 @@ "open": "abrir", "close": "cerrar" }, - "tags": { "title": "Etiquetas", "no_tags": "No se encontraron etiquetas", @@ -172,13 +160,11 @@ "clear_all": "Limpiar filtros de etiquetas", "filter_by": "Filtrar por {{tag}} ({{count}} notas)" }, - "outline": { "title": "Esquema", "no_headings": "No se encontraron encabezados", "hint": "Añade encabezados usando sintaxis #" }, - "backlinks": { "title": "Backlinks", "no_backlinks": "No se encontraron backlinks", @@ -186,21 +172,18 @@ "select_note": "Selecciona una nota para ver backlinks", "more_refs": "más" }, - "stats": { "words": "palabras", "reading_time": "~{{minutes}}m de lectura", "links": "{{count}} enlaces", "click_details": "▼ Clic para detalles" }, - "graph": { "title": "Vista de Grafo", "empty": "No hay conexiones para mostrar", "markdown_links": "Enlaces Markdown", "click_hint": "Clic: seleccionar • Doble clic: abrir" }, - "templates": { "title": "Plantillas", "select": "Selecciona una plantilla...", @@ -214,11 +197,9 @@ "available_placeholders": "Marcadores disponibles", "create_note": "Crear Nota" }, - "export": { "failed": "Error al exportar HTML: {{error}}" }, - "login": { "title": "Iniciar sesión", "tagline": "Tu Base de Conocimientos Autoalojada", @@ -227,7 +208,6 @@ "footer": "🔒 Seguro y Autoalojado", "error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo." }, - "drawing": { "tool_freehand": "Lápiz — trazo libre", "tool_line": "Línea recta", @@ -235,23 +215,19 @@ "tool_ellipse": "Círculo", "color": "Color", "width": "Grosor del trazo", - "saved": "Dibujo guardado", "save_hint": "Guardar dibujo (Ctrl+S)" }, - "media": { "confirm_delete": "¿Eliminar \"{{name}}\"?", "upload_failed": "Error al subir archivo", "no_valid_files": "No se encontraron archivos válidos. Soporta: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Error al mover la nota.", "failed_folder": "Error al mover la carpeta.", "failed_media": "Error al mover el archivo multimedia." }, - "search": { "previous": "Anterior (Shift+F3)", "next": "Siguiente (F3)", @@ -262,22 +238,18 @@ "no_results": "Ninguna nota contiene \"{{query}}\"", "searching": "Buscando \"{{query}}\"..." }, - "theme": { "title": "Tema" }, - "language": { "title": "Idioma" }, - "syntax_highlight": { "title": "Resaltado de Sintaxis del Editor", "description": "Colorear sintaxis markdown en el editor", "enable": "Activar resaltado de sintaxis", "disable": "Desactivar resaltado de sintaxis" }, - "settings": { "account": "Cuenta", "logout": "Cerrar sesión", @@ -288,7 +260,6 @@ "tab_inserts_tab": "Tabulador inserta tabulación", "tab_inserts_tab_desc": "Pulsa Tab para insertar un tabulador en lugar de cambiar el foco" }, - "homepage": { "title": "Inicio", "welcome": "Bienvenido a NoteDiscovery", @@ -301,7 +272,6 @@ "folder_singular": "carpeta", "folder_plural": "carpetas" }, - "format": { "bold": "Negrita", "italic": "Cursiva", @@ -317,23 +287,19 @@ "checkbox": "Casilla de verificación", "table": "Tabla" }, - "validation": { "forbidden_chars": "El nombre contiene caracteres no permitidos: {{chars}}", "reserved_name": "Este nombre está reservado por el sistema operativo.", "invalid_dot": "El nombre no puede ser solo un punto.", "trailing_dot_space": "El nombre no puede terminar con un punto o espacio." }, - "support": { "enjoying_demo": "Apoya este proyecto", "deploy_own": "Despliega tu propia instancia", "thank_you": "¡Gracias por tu apoyo! 💚" }, - "demo": { "title": "MODO DEMO", "warning": "El contenido se reinicia diariamente. Los cambios pueden ser sobrescritos por otros usuarios." } } - diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 0de70c2..8feac0d 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -4,11 +4,9 @@ "name": "Français", "flag": "🇫🇷" }, - "app": { "tagline": "Votre Base de Connaissances Auto-Hébergée" }, - "common": { "save": "Enregistrer", "cancel": "Annuler", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Copier dans le presse-papiers", "failed": "Échec de {{action}}. Veuillez réessayer." }, - "toast": { "region_label": "Notifications", "dismiss": "Fermer" }, - "sidebar": { "title": "FICHIERS", "favorites_title": "Favoris", @@ -73,7 +69,6 @@ "settings_title": "PARAMÈTRES", "filtered_notes": "Notes Filtrées" }, - "editor": { "placeholder": "Commencez à écrire en markdown...", "drop_hint": "💡 Déposez ici pour insérer à la position du curseur...", @@ -86,7 +81,6 @@ "hours_ago": "il y a {{count}}h", "days_ago": "il y a {{count}}j" }, - "notes": { "confirm_delete": "Supprimer \"{{name}}\" ?", "already_exists": "Une note nommée \"{{name}}\" existe déjà à cet emplacement.\nVeuillez choisir un autre nom.", @@ -102,7 +96,6 @@ "no_content": "Aucun contenu à exporter", "open_first": "Veuillez d'abord ouvrir une note avant de télécharger des images." }, - "folders": { "confirm_delete": "⚠️ ATTENTION ⚠️\n\nÊtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" ?\n\nCeci supprimera DÉFINITIVEMENT :\n• Toutes les notes dans ce dossier\n• Tous les sous-dossiers et leur contenu\n\nCette action est IRRÉVERSIBLE !", "already_exists": "Un dossier nommé \"{{name}}\" existe déjà à cet emplacement.\nVeuillez choisir un autre nom.", @@ -119,7 +112,6 @@ "cannot_move_into_self": "Impossible de déplacer un dossier dans lui-même ou dans un sous-dossier.", "empty": "vide" }, - "toolbar": { "undo": "Annuler (Ctrl+Z)", "redo": "Rétablir (Ctrl+Y)", @@ -132,14 +124,12 @@ "add_favorite": "Ajouter aux favoris", "remove_favorite": "Retirer des favoris" }, - "zen_mode": { "title": "Mode Zen", "tooltip": "Mode Zen (Ctrl+Alt+Z)", "exit": "Quitter le Mode Zen", "exit_hint": "Quitter le Mode Zen (Échap)" }, - "share": { "button_tooltip": "Partager la note", "modal_title": "Partager la Note", @@ -154,7 +144,6 @@ "show_qr": "Afficher le code QR", "hide_qr": "Masquer le code QR" }, - "quick_switcher": { "placeholder": "Rechercher des notes...", "no_results": "Aucun résultat", @@ -163,7 +152,6 @@ "open": "ouvrir", "close": "fermer" }, - "tags": { "title": "Étiquettes", "no_tags": "Aucune étiquette trouvée", @@ -172,13 +160,11 @@ "clear_all": "Effacer les filtres d'étiquettes", "filter_by": "Filtrer par {{tag}} ({{count}} notes)" }, - "outline": { "title": "Plan", "no_headings": "Aucun titre trouvé", "hint": "Ajoutez des titres avec la syntaxe #" }, - "backlinks": { "title": "Rétroliens", "no_backlinks": "Aucun rétrolien trouvé", @@ -186,21 +172,18 @@ "select_note": "Sélectionnez une note pour voir les rétroliens", "more_refs": "de plus" }, - "stats": { "words": "mots", "reading_time": "~{{minutes}}m de lecture", "links": "{{count}} liens", "click_details": "▼ Cliquez pour les détails" }, - "graph": { "title": "Vue Graphique", "empty": "Aucune connexion à afficher", "markdown_links": "Liens Markdown", "click_hint": "Clic : sélectionner • Double-clic : ouvrir" }, - "templates": { "title": "Modèles", "select": "Sélectionner un modèle...", @@ -214,11 +197,9 @@ "available_placeholders": "Variables disponibles", "create_note": "Créer la Note" }, - "export": { "failed": "Échec de l'export HTML : {{error}}" }, - "login": { "title": "Connexion", "tagline": "Votre Base de Connaissances Auto-Hébergée", @@ -227,7 +208,6 @@ "footer": "🔒 Sécurisé et Auto-Hébergé", "error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer." }, - "drawing": { "tool_freehand": "Crayon — main levée", "tool_line": "Ligne droite", @@ -235,23 +215,19 @@ "tool_ellipse": "Cercle", "color": "Couleur", "width": "Épaisseur du trait", - "saved": "Dessin enregistré", "save_hint": "Enregistrer le dessin (Ctrl+S)" }, - "media": { "confirm_delete": "Supprimer \"{{name}}\" ?", "upload_failed": "Échec du téléchargement du fichier", "no_valid_files": "Aucun fichier valide trouvé. Supporté : JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Échec du déplacement de la note.", "failed_folder": "Échec du déplacement du dossier.", "failed_media": "Échec du déplacement du fichier multimédia." }, - "search": { "previous": "Précédent (Maj+F3)", "next": "Suivant (F3)", @@ -262,22 +238,18 @@ "no_results": "Aucune note ne contient \"{{query}}\"", "searching": "Recherche de \"{{query}}\"..." }, - "theme": { "title": "Thème" }, - "language": { "title": "Langue" }, - "syntax_highlight": { "title": "Coloration Syntaxique de l'Éditeur", "description": "Coloriser la syntaxe markdown dans l'éditeur", "enable": "Activer la coloration syntaxique", "disable": "Désactiver la coloration syntaxique" }, - "settings": { "account": "Compte", "logout": "Déconnexion", @@ -288,7 +260,6 @@ "tab_inserts_tab": "Tab insère une tabulation", "tab_inserts_tab_desc": "Appuyez sur Tab pour insérer une tabulation au lieu de changer le focus" }, - "homepage": { "title": "Accueil", "welcome": "Bienvenue sur NoteDiscovery", @@ -301,7 +272,6 @@ "folder_singular": "dossier", "folder_plural": "dossiers" }, - "format": { "bold": "Gras", "italic": "Italique", @@ -317,23 +287,19 @@ "checkbox": "Case à cocher", "table": "Tableau" }, - "validation": { "forbidden_chars": "Le nom contient des caractères interdits : {{chars}}", "reserved_name": "Ce nom est réservé par le système d'exploitation.", "invalid_dot": "Le nom ne peut pas être juste un point.", "trailing_dot_space": "Le nom ne peut pas se terminer par un point ou un espace." }, - "support": { "enjoying_demo": "Soutenez ce projet", "deploy_own": "Déployez votre propre instance", "thank_you": "Merci pour votre soutien ! 💚" }, - "demo": { "title": "MODE DÉMO", "warning": "Le contenu est réinitialisé quotidiennement. Les modifications peuvent être écrasées par d'autres utilisateurs." } } - diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 63aa67c..1189fdd 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -4,11 +4,9 @@ "name": "Magyar", "flag": "🇭🇺" }, - "app": { "tagline": "A Saját, Önálló Tudásbázisod" }, - "common": { "save": "Mentés", "cancel": "Mégse", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Másolás", "failed": "Hiba a(z) {{action}} során. Kérlek, próbáld újra." }, - "toast": { "region_label": "Értesítések", "dismiss": "Bezárás" }, - "sidebar": { "title": "FÁJLOK", "favorites_title": "Kedvencek", @@ -73,7 +69,6 @@ "settings_title": "BEÁLLÍTÁSOK", "filtered_notes": "Szűrt jegyzetek" }, - "editor": { "placeholder": "Kezdj el írni markdown-ban...", "drop_hint": "💡 Beszúrás ide...", @@ -86,7 +81,6 @@ "hours_ago": "{{count}} órája", "days_ago": "{{count}} napja" }, - "notes": { "confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?", "already_exists": "A(z) \"{{name}}\" nevű jegyzet már létezik.\nKérlek válassz egy másik nevet.", @@ -102,7 +96,6 @@ "no_content": "Nincs exportálható jegyzettartalom", "open_first": "Kérlek előbb nyiss meg egy jegyzetet, mielőtt képeket töltenél fel." }, - "folders": { "confirm_delete": "⚠️ FIGYELMEZTETÉS ⚠️\n\nBiztosan törölni szeretnéd a(z) \"{{name}}\" mappát?\n\nEz VÉGLEGESEN törli a következőket:\n• A mappában található összes jegyzet\n• Az összes almappa és azok tartalma \n\nEz a művelet NEM vonható vissza!", "already_exists": "A(z) \"{{name}}\" nevű mappa már létezik.\nKérlek válassz egy másik nevet.", @@ -119,7 +112,6 @@ "cannot_move_into_self": "A mappa nem mozgatható saját magába vagy bármelyik almappájába.", "empty": "üres" }, - "toolbar": { "undo": "Visszavon (Ctrl+Z)", "redo": "Helyrehoz (Ctrl+Y)", @@ -132,14 +124,12 @@ "add_favorite": "Hozzáadás a kedvencekhez", "remove_favorite": "Eltávolítás a kedvencek közül" }, - "zen_mode": { "title": "Zen Mód", "tooltip": "Zen Mód (Ctrl+Alt+Z)", "exit": "Kilépés a Zen Módból", "exit_hint": "Kilépés a Zen Módból (Esc)" }, - "share": { "button_tooltip": "Jegyzet megosztása", "modal_title": "Jegyzet Megosztása", @@ -154,7 +144,6 @@ "show_qr": "QR-Kód Megjelenítése", "hide_qr": "QR-Kód Elrejtése" }, - "quick_switcher": { "placeholder": "Írj a jegyzetek kereséséhez...", "no_results": "Nincs találat", @@ -163,7 +152,6 @@ "open": "nyit", "close": "csuk" }, - "tags": { "title": "Címkék", "no_tags": "Nem találhatók címkék", @@ -172,13 +160,11 @@ "clear_all": "Szűrők törlése", "filter_by": "Szűrés {{tag}} ({{count}} jegyzetek)" }, - "outline": { "title": "Fejlécek", "no_headings": "Nincsenek fejlécek", "hint": "Fejléc hozzáadása a következővel: # " }, - "backlinks": { "title": "Bejövő linkek", "no_backlinks": "Nincsenek bejövő linkek", @@ -186,21 +172,18 @@ "select_note": "Válassz ki egy jegyzetet a bejövő linkek megtekintéséhez", "more_refs": "továbbiak" }, - "stats": { "words": "szavak", "reading_time": "~{{minutes}} percnyi olvasás", "links": "{{count}} hivatkozások", "click_details": "▼ Kattints a részletekért" }, - "graph": { "title": "Grafikon nézet", "empty": "Nincsenek megjeleníthető kapcsolatok", "markdown_links": "Markdown hivatkozások", "click_hint": "Kattints: kiválasztás • Dupla kattintás: megnyitás" }, - "templates": { "title": "Sablonok", "select": "Válassz egy sablont...", @@ -214,11 +197,9 @@ "available_placeholders": "Elérhető helyőrzők", "create_note": "Jegyzet létrehozása" }, - "export": { "failed": "Hiba a HTML exportálása során: {{error}}" }, - "login": { "title": "Bejelentkezés", "tagline": "A Saját, Önálló Tudásbázisod", @@ -227,7 +208,6 @@ "footer": "🔒 Biztonságos & Saját Kiszolgálású", "error_incorrect_password": "Helytelen jelszó. Próbáld újra." }, - "drawing": { "tool_freehand": "Ceruza — szabadkézi", "tool_line": "Egyenes vonal", @@ -235,23 +215,19 @@ "tool_ellipse": "Kör", "color": "Szín", "width": "Vonalvastagság", - "saved": "Rajz elmentve", "save_hint": "Rajz mentése (Ctrl+S)" }, - "media": { "confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?", "upload_failed": "Hiba a fájl feltöltése során", "no_valid_files": "Nem található érvényes fájl. Támogatott formátumok: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Hiba a jegyzet mozgatásánál.", "failed_folder": "Hiba a mappa mozgatásánál.", "failed_media": "Hiba a médiafájl mozgatásánál." }, - "search": { "previous": "Előző (Shift+F3)", "next": "Következő (F3)", @@ -262,22 +238,18 @@ "no_results": "Nincs jegyzet amely tartalmazza a következőt: \"{{query}}\"", "searching": "\"{{query}}\" keresése..." }, - "theme": { "title": "Téma" }, - "language": { "title": "Nyelv" }, - "syntax_highlight": { "title": "Szintaxisok kiemelése", "description": "Markdown szintaxisok színezése", "enable": "Szintaxiskiemelések engedélyezése", "disable": "Szintaxiskiemelések letiltása" }, - "settings": { "account": "Fiók", "logout": "Kijelentkezés", @@ -288,7 +260,6 @@ "tab_inserts_tab": "Tab billentyű több helyet hagy a szövegben", "tab_inserts_tab_desc": "Nyomd meg a Tab-ot, hogy több helyet hagyj a szövegben a fókuszváltás helyett" }, - "homepage": { "title": "Főoldal", "welcome": "Üdvözöllek a NoteDiscovery-ben", @@ -301,7 +272,6 @@ "folder_singular": "mappa", "folder_plural": "mappa" }, - "format": { "bold": "Félkövér", "italic": "Dőlt", @@ -317,20 +287,17 @@ "checkbox": "Jelölőnégyzet", "table": "Táblázat" }, - "validation": { "forbidden_chars": "A név tiltott karaktereket tartalmaz: {{chars}}", "reserved_name": "Ezt a nevet az operációs rendszer tartja fenn.", "invalid_dot": "A név nem állhat csak egy pontból.", "trailing_dot_space": "A név nem végződhet ponttal vagy szóközzel." }, - "support": { "enjoying_demo": "Támogasd ezt a projektet", "deploy_own": "Saját példány telepítése", "thank_you": "Köszönjük a támogatásodat! 💚" }, - "demo": { "title": "DEMÓ MÓD", "warning": "A tartalom naponta visszaáll. A módosításokat más felhasználók felülírhatják." diff --git a/locales/it-IT.json b/locales/it-IT.json index c216a2b..aa7ca6c 100644 --- a/locales/it-IT.json +++ b/locales/it-IT.json @@ -4,11 +4,9 @@ "name": "Italiano", "flag": "🇮🇹" }, - "app": { "tagline": "La tua base di conoscenza personale" }, - "common": { "save": "Salva", "cancel": "Annulla", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Copia negli appunti", "failed": "Impossibile {{action}}. Riprova." }, - "toast": { "region_label": "Notifiche", "dismiss": "Chiudi" }, - "sidebar": { "title": "FILE", "favorites_title": "Preferiti", @@ -72,7 +68,6 @@ "settings_title": "IMPOSTAZIONI", "filtered_notes": "Note Filtrate" }, - "editor": { "placeholder": "Inizia a scrivere in markdown...", "drop_hint": "💡 Rilascia qui per inserire alla posizione del cursore...", @@ -85,7 +80,6 @@ "hours_ago": "{{count}}h fa", "days_ago": "{{count}}g fa" }, - "notes": { "confirm_delete": "Eliminare \"{{name}}\"?", "already_exists": "Una nota chiamata \"{{name}}\" esiste già in questa posizione.\nScegli un nome diverso.", @@ -101,7 +95,6 @@ "no_content": "Nessun contenuto da esportare", "open_first": "Apri prima una nota per caricare immagini." }, - "folders": { "confirm_delete": "⚠️ ATTENZIONE ⚠️\n\nSei sicuro di voler eliminare la cartella \"{{name}}\"?\n\nQuesto eliminerà PERMANENTEMENTE:\n• Tutte le note in questa cartella\n• Tutte le sottocartelle e i loro contenuti\n\nQuesta azione NON PUÒ essere annullata!", "already_exists": "Una cartella chiamata \"{{name}}\" esiste già in questa posizione.\nScegli un nome diverso.", @@ -118,7 +111,6 @@ "cannot_move_into_self": "Impossibile spostare la cartella in se stessa o in una sottocartella.", "empty": "vuota" }, - "toolbar": { "undo": "Annulla (Ctrl+Z)", "redo": "Ripeti (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "Aggiungi ai preferiti", "remove_favorite": "Rimuovi dai preferiti" }, - "zen_mode": { "title": "Modalità Zen", "tooltip": "Modalità Zen (Ctrl+Alt+Z)", "exit": "Esci dalla Modalità Zen", "exit_hint": "Esci dalla Modalità Zen (Esc)" }, - "share": { "button_tooltip": "Condividi nota", "modal_title": "Condividi Nota", @@ -153,7 +143,6 @@ "show_qr": "Mostra codice QR", "hide_qr": "Nascondi codice QR" }, - "quick_switcher": { "placeholder": "Cerca note...", "no_results": "Nessun risultato", @@ -162,7 +151,6 @@ "open": "apri", "close": "chiudi" }, - "tags": { "title": "Tag", "no_tags": "Nessun tag trovato", @@ -171,13 +159,11 @@ "clear_all": "Cancella filtri tag", "filter_by": "Filtra per {{tag}} ({{count}} note)" }, - "outline": { "title": "Indice", "no_headings": "Nessun titolo trovato", "hint": "Aggiungi titoli usando la sintassi #" }, - "backlinks": { "title": "Backlink", "no_backlinks": "Nessun backlink trovato", @@ -185,21 +171,18 @@ "select_note": "Seleziona una nota per vedere i backlink", "more_refs": "altri" }, - "stats": { "words": "parole", "reading_time": "~{{minutes}}m lettura", "links": "{{count}} link", "click_details": "▼ Clicca per dettagli" }, - "graph": { "title": "Grafo", "empty": "Nessuna connessione da visualizzare", "markdown_links": "Link markdown", "click_hint": "Clic: seleziona • Doppio clic: apri" }, - "templates": { "title": "Modelli", "select": "Seleziona un modello...", @@ -213,11 +196,9 @@ "available_placeholders": "Segnaposto disponibili", "create_note": "Crea Nota" }, - "export": { "failed": "Esportazione HTML fallita: {{error}}" }, - "login": { "title": "Accesso", "tagline": "La tua base di conoscenza personale", @@ -226,7 +207,6 @@ "footer": "🔒 Sicuro e ospitato localmente", "error_incorrect_password": "Password errata. Riprova." }, - "drawing": { "tool_freehand": "Matita — mano libera", "tool_line": "Linea retta", @@ -234,23 +214,19 @@ "tool_ellipse": "Cerchio", "color": "Colore", "width": "Spessore tratto", - "saved": "Disegno salvato", "save_hint": "Salva disegno (Ctrl+S)" }, - "media": { "confirm_delete": "Eliminare \"{{name}}\"?", "upload_failed": "Caricamento file fallito", "no_valid_files": "Nessun file valido trovato. Supportati: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Impossibile spostare la nota.", "failed_folder": "Impossibile spostare la cartella.", "failed_media": "Impossibile spostare il file multimediale." }, - "search": { "previous": "Precedente (Shift+F3)", "next": "Successivo (F3)", @@ -261,22 +237,18 @@ "no_results": "Nessuna nota contiene \"{{query}}\"", "searching": "Ricerca di \"{{query}}\"..." }, - "theme": { "title": "Tema" }, - "language": { "title": "Lingua" }, - "syntax_highlight": { "title": "Evidenziazione Sintassi Editor", "description": "Colora la sintassi markdown nell'editor", "enable": "Attiva evidenziazione sintassi", "disable": "Disattiva evidenziazione sintassi" }, - "settings": { "account": "Account", "logout": "Esci", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tab inserisce tabulazione", "tab_inserts_tab_desc": "Premi Tab per inserire una tabulazione invece di cambiare focus" }, - "homepage": { "title": "Home", "welcome": "Benvenuto in NoteDiscovery", @@ -300,7 +271,6 @@ "folder_singular": "cartella", "folder_plural": "cartelle" }, - "format": { "bold": "Grassetto", "italic": "Corsivo", @@ -316,20 +286,17 @@ "checkbox": "Casella di controllo", "table": "Tabella" }, - "validation": { "forbidden_chars": "Il nome contiene caratteri non consentiti: {{chars}}", "reserved_name": "Questo nome è riservato dal sistema operativo.", "invalid_dot": "Il nome non può essere solo un punto.", "trailing_dot_space": "Il nome non può terminare con un punto o uno spazio." }, - "support": { "enjoying_demo": "Supporta questo progetto", "deploy_own": "Crea la tua istanza", "thank_you": "Grazie per il tuo supporto! 💚" }, - "demo": { "title": "MODALITÀ DEMO", "warning": "I contenuti vengono ripristinati ogni giorno. Le modifiche potrebbero essere sovrascritte da altri utenti." diff --git a/locales/ja-JP.json b/locales/ja-JP.json index 2932ab2..5436de5 100644 --- a/locales/ja-JP.json +++ b/locales/ja-JP.json @@ -4,11 +4,9 @@ "name": "日本語", "flag": "🇯🇵" }, - "app": { "tagline": "セルフホスト型ナレッジベース" }, - "common": { "save": "保存", "cancel": "キャンセル", @@ -28,12 +26,10 @@ "copy_to_clipboard": "クリップボードにコピー", "failed": "{{action}}に失敗しました。もう一度お試しください。" }, - "toast": { "region_label": "通知", "dismiss": "閉じる" }, - "sidebar": { "title": "ファイル", "favorites_title": "お気に入り", @@ -72,7 +68,6 @@ "settings_title": "設定", "filtered_notes": "フィルター済みノート" }, - "editor": { "placeholder": "マークダウンで書き始める...", "drop_hint": "💡 ここにドロップしてカーソル位置に挿入...", @@ -85,7 +80,6 @@ "hours_ago": "{{count}}時間前", "days_ago": "{{count}}日前" }, - "notes": { "confirm_delete": "「{{name}}」を削除しますか?", "already_exists": "「{{name}}」という名前のノートは既に存在します。\n別の名前を選択してください。", @@ -101,7 +95,6 @@ "no_content": "エクスポートするノートの内容がありません", "open_first": "画像をアップロードする前にノートを開いてください。" }, - "folders": { "confirm_delete": "⚠️ 警告 ⚠️\n\n本当にフォルダ「{{name}}」を削除しますか?\n\n以下が完全に削除されます:\n• このフォルダ内のすべてのノート\n• すべてのサブフォルダとその内容\n\nこの操作は元に戻せません!", "already_exists": "「{{name}}」という名前のフォルダは既に存在します。\n別の名前を選択してください。", @@ -118,7 +111,6 @@ "cannot_move_into_self": "フォルダを自身またはそのサブフォルダに移動できません。", "empty": "空" }, - "toolbar": { "undo": "元に戻す (Ctrl+Z)", "redo": "やり直す (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "お気に入りに追加", "remove_favorite": "お気に入りから削除" }, - "zen_mode": { "title": "集中モード", "tooltip": "集中モード (Ctrl+Alt+Z)", "exit": "集中モードを終了", "exit_hint": "集中モードを終了 (Esc)" }, - "share": { "button_tooltip": "ノートを共有", "modal_title": "ノートを共有", @@ -153,7 +143,6 @@ "show_qr": "QRコードを表示", "hide_qr": "QRコードを非表示" }, - "quick_switcher": { "placeholder": "ノートを検索...", "no_results": "結果なし", @@ -162,7 +151,6 @@ "open": "開く", "close": "閉じる" }, - "tags": { "title": "タグ", "no_tags": "タグが見つかりません", @@ -171,13 +159,11 @@ "clear_all": "タグフィルターをクリア", "filter_by": "{{tag}}でフィルター({{count}}件)" }, - "outline": { "title": "アウトライン", "no_headings": "見出しが見つかりません", "hint": "# 記法で見出しを追加" }, - "backlinks": { "title": "バックリンク", "no_backlinks": "バックリンクが見つかりません", @@ -185,21 +171,18 @@ "select_note": "バックリンクを表示するノートを選択", "more_refs": "その他" }, - "stats": { "words": "語", "reading_time": "約{{minutes}}分で読了", "links": "{{count}}件のリンク", "click_details": "▼ 詳細を表示" }, - "graph": { "title": "グラフ", "empty": "表示する接続がありません", "markdown_links": "マークダウンリンク", "click_hint": "クリック: 選択 • ダブルクリック: 開く" }, - "templates": { "title": "テンプレート", "select": "テンプレートを選択...", @@ -213,11 +196,9 @@ "available_placeholders": "利用可能なプレースホルダー", "create_note": "ノートを作成" }, - "export": { "failed": "HTMLエクスポートに失敗しました: {{error}}" }, - "login": { "title": "ログイン", "tagline": "セルフホスト型ナレッジベース", @@ -226,7 +207,6 @@ "footer": "🔒 安全なセルフホスト", "error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。" }, - "drawing": { "tool_freehand": "鉛筆(フリーハンド)", "tool_line": "直線", @@ -234,23 +214,19 @@ "tool_ellipse": "円", "color": "色", "width": "線の太さ", - "saved": "保存しました", "save_hint": "お絵かきを保存(Ctrl+S)" }, - "media": { "confirm_delete": "「{{name}}」を削除しますか?", "upload_failed": "ファイルのアップロードに失敗しました", "no_valid_files": "有効なファイルが見つかりません。対応: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "ノートの移動に失敗しました。", "failed_folder": "フォルダの移動に失敗しました。", "failed_media": "メディアファイルの移動に失敗しました。" }, - "search": { "previous": "前へ (Shift+F3)", "next": "次へ (F3)", @@ -261,22 +237,18 @@ "no_results": "「{{query}}」を含むノートはありません", "searching": "「{{query}}」を検索中..." }, - "theme": { "title": "テーマ" }, - "language": { "title": "言語" }, - "syntax_highlight": { "title": "エディタのシンタックスハイライト", "description": "エディタでマークダウン構文を色分け表示", "enable": "シンタックスハイライトを有効化", "disable": "シンタックスハイライトを無効化" }, - "settings": { "account": "アカウント", "logout": "ログアウト", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tabキーでタブを挿入", "tab_inserts_tab_desc": "Tabキーでフォーカス移動ではなくタブ文字を挿入" }, - "homepage": { "title": "ホーム", "welcome": "NoteDiscoveryへようこそ", @@ -300,7 +271,6 @@ "folder_singular": "フォルダ", "folder_plural": "フォルダ" }, - "format": { "bold": "太字", "italic": "斜体", @@ -316,20 +286,17 @@ "checkbox": "チェックボックス", "table": "表" }, - "validation": { "forbidden_chars": "名前に使用できない文字が含まれています: {{chars}}", "reserved_name": "この名前はオペレーティングシステムで予約されています。", "invalid_dot": "名前をドットだけにすることはできません。", "trailing_dot_space": "名前の末尾にドットやスペースは使用できません。" }, - "support": { "enjoying_demo": "このプロジェクトを支援", "deploy_own": "自分のインスタンスをデプロイ", "thank_you": "ご支援ありがとうございます!💚" }, - "demo": { "title": "デモモード", "warning": "コンテンツは毎日リセットされます。変更は他のユーザーによって上書きされる場合があります。" diff --git a/locales/ru-RU.json b/locales/ru-RU.json index dd1685e..79b2387 100644 --- a/locales/ru-RU.json +++ b/locales/ru-RU.json @@ -4,11 +4,9 @@ "name": "Русский", "flag": "🇷🇺" }, - "app": { "tagline": "Ваша локальная база знаний" }, - "common": { "save": "Сохранить", "cancel": "Отмена", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Копировать в буфер обмена", "failed": "Не удалось {{action}}. Попробуйте снова." }, - "toast": { "region_label": "Уведомления", "dismiss": "Закрыть" }, - "sidebar": { "title": "ФАЙЛЫ", "favorites_title": "Избранное", @@ -72,7 +68,6 @@ "settings_title": "НАСТРОЙКИ", "filtered_notes": "Отфильтрованные заметки" }, - "editor": { "placeholder": "Начните писать в markdown...", "drop_hint": "💡 Отпустите здесь для вставки в позицию курсора...", @@ -85,7 +80,6 @@ "hours_ago": "{{count}} ч. назад", "days_ago": "{{count}} дн. назад" }, - "notes": { "confirm_delete": "Удалить «{{name}}»?", "already_exists": "Заметка с именем «{{name}}» уже существует.\nВыберите другое имя.", @@ -101,7 +95,6 @@ "no_content": "Нет содержимого для экспорта", "open_first": "Сначала откройте заметку для загрузки изображений." }, - "folders": { "confirm_delete": "⚠️ ВНИМАНИЕ ⚠️\n\nВы уверены, что хотите удалить папку «{{name}}»?\n\nБудет БЕЗВОЗВРАТНО удалено:\n• Все заметки в этой папке\n• Все подпапки и их содержимое\n\nЭто действие НЕЛЬЗЯ отменить!", "already_exists": "Папка с именем «{{name}}» уже существует.\nВыберите другое имя.", @@ -118,7 +111,6 @@ "cannot_move_into_self": "Нельзя переместить папку в себя или в подпапку.", "empty": "пусто" }, - "toolbar": { "undo": "Отменить (Ctrl+Z)", "redo": "Повторить (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "Добавить в избранное", "remove_favorite": "Удалить из избранного" }, - "zen_mode": { "title": "Режим концентрации", "tooltip": "Режим концентрации (Ctrl+Alt+Z)", "exit": "Выйти из режима концентрации", "exit_hint": "Выйти из режима концентрации (Esc)" }, - "share": { "button_tooltip": "Поделиться заметкой", "modal_title": "Поделиться заметкой", @@ -153,7 +143,6 @@ "show_qr": "Показать QR-код", "hide_qr": "Скрыть QR-код" }, - "quick_switcher": { "placeholder": "Поиск заметок...", "no_results": "Ничего не найдено", @@ -162,7 +151,6 @@ "open": "открыть", "close": "закрыть" }, - "tags": { "title": "Теги", "no_tags": "Теги не найдены", @@ -171,13 +159,11 @@ "clear_all": "Сбросить фильтры тегов", "filter_by": "Фильтр по {{tag}} ({{count}} заметок)" }, - "outline": { "title": "Содержание", "no_headings": "Заголовки не найдены", "hint": "Добавьте заголовки с помощью #" }, - "backlinks": { "title": "Обратные ссылки", "no_backlinks": "Обратные ссылки не найдены", @@ -185,21 +171,18 @@ "select_note": "Выберите заметку, чтобы увидеть обратные ссылки", "more_refs": "ещё" }, - "stats": { "words": "слов", "reading_time": "~{{minutes}} мин. чтения", "links": "{{count}} ссылок", "click_details": "▼ Нажмите для подробностей" }, - "graph": { "title": "Граф связей", "empty": "Нет связей для отображения", "markdown_links": "Markdown-ссылки", "click_hint": "Клик: выбрать • Двойной клик: открыть" }, - "templates": { "title": "Шаблоны", "select": "Выберите шаблон...", @@ -213,11 +196,9 @@ "available_placeholders": "Доступные плейсхолдеры", "create_note": "Создать заметку" }, - "export": { "failed": "Ошибка экспорта HTML: {{error}}" }, - "login": { "title": "Вход", "tagline": "Ваша локальная база знаний", @@ -226,7 +207,6 @@ "footer": "🔒 Безопасно и локально", "error_incorrect_password": "Неверный пароль. Попробуйте снова." }, - "drawing": { "tool_freehand": "Карандаш — от руки", "tool_line": "Прямая линия", @@ -234,23 +214,19 @@ "tool_ellipse": "Круг", "color": "Цвет", "width": "Толщина линии", - "saved": "Рисунок сохранён", "save_hint": "Сохранить рисунок (Ctrl+S)" }, - "media": { "confirm_delete": "Удалить «{{name}}»?", "upload_failed": "Не удалось загрузить файл", "no_valid_files": "Не найдено подходящих файлов. Поддерживает: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Не удалось переместить заметку.", "failed_folder": "Не удалось переместить папку.", "failed_media": "Не удалось переместить медиафайл." }, - "search": { "previous": "Предыдущий (Shift+F3)", "next": "Следующий (F3)", @@ -261,22 +237,18 @@ "no_results": "Нет заметок с \"{{query}}\"", "searching": "Поиск \"{{query}}\"..." }, - "theme": { "title": "Тема" }, - "language": { "title": "Язык" }, - "syntax_highlight": { "title": "Подсветка синтаксиса", "description": "Подсветка markdown-синтаксиса в редакторе", "enable": "Включить подсветку синтаксиса", "disable": "Выключить подсветку синтаксиса" }, - "settings": { "account": "Аккаунт", "logout": "Выйти", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tab вставляет табуляцию", "tab_inserts_tab_desc": "Нажмите Tab для вставки табуляции вместо переключения фокуса" }, - "homepage": { "title": "Главная", "welcome": "Добро пожаловать в NoteDiscovery", @@ -300,7 +271,6 @@ "folder_singular": "папка", "folder_plural": "папок" }, - "format": { "bold": "Жирный", "italic": "Курсив", @@ -316,20 +286,17 @@ "checkbox": "Чекбокс", "table": "Таблица" }, - "validation": { "forbidden_chars": "Имя содержит запрещённые символы: {{chars}}", "reserved_name": "Это имя зарезервировано операционной системой.", "invalid_dot": "Имя не может состоять только из точки.", "trailing_dot_space": "Имя не может заканчиваться точкой или пробелом." }, - "support": { "enjoying_demo": "Поддержать проект", "deploy_own": "Развернуть свой экземпляр", "thank_you": "Спасибо за поддержку! 💚" }, - "demo": { "title": "ДЕМО-РЕЖИМ", "warning": "Содержимое сбрасывается ежедневно. Изменения могут быть перезаписаны другими пользователями." diff --git a/locales/sl-SI.json b/locales/sl-SI.json index b219d6b..68d2511 100644 --- a/locales/sl-SI.json +++ b/locales/sl-SI.json @@ -4,11 +4,9 @@ "name": "Slovenščina", "flag": "🇸🇮" }, - "app": { "tagline": "Vaša lastna baza znanja" }, - "common": { "save": "Shrani", "cancel": "Prekliči", @@ -28,12 +26,10 @@ "copy_to_clipboard": "Kopiraj v odložišče", "failed": "Napaka pri {{action}}. Prosimo, poskusite znova." }, - "toast": { "region_label": "Obvestila", "dismiss": "Zapri" }, - "sidebar": { "title": "DATOTEKE", "favorites_title": "Priljubljene", @@ -72,7 +68,6 @@ "settings_title": "NASTAVITVE", "filtered_notes": "Filtrirani zapiski" }, - "editor": { "placeholder": "Začnite pisati v markdownu...", "drop_hint": "💡 Spustite tukaj za vstavljanje na mesto kazalca...", @@ -85,7 +80,6 @@ "hours_ago": "pred {{count}} h", "days_ago": "pred {{count}} d" }, - "notes": { "confirm_delete": "Izbrišem \"{{name}}\"?", "already_exists": "Zapis z imenom \"{{name}}\" na tej lokaciji že obstaja.\nProsimo, izberite drugo ime.", @@ -101,7 +95,6 @@ "no_content": "Ni vsebine za izvoz", "open_first": "Prosimo, najprej odprite zapis, preden naložite slike." }, - "folders": { "confirm_delete": "⚠️ OPOZORILO ⚠️\n\nAli ste prepričani, da želite izbrisati mapo \"{{name}}\"?\n\nTo bo TRAJNO izbrisalo:\n• Vse zapiske v tej mapi\n• Vse podmape in njihovo vsebino\n\nTe akcije NI mogoče preklicati!", "already_exists": "Mapa z imenom \"{{name}}\" na tej lokaciji že obstaja.\nProsimo, izberite drugo ime.", @@ -118,7 +111,6 @@ "cannot_move_into_self": "Mape ni mogoče premakniti vase ali v njeno podmapo.", "empty": "prazno" }, - "toolbar": { "undo": "Razveljavi (Ctrl+Z)", "redo": "Ponovi (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "Dodaj med priljubljene", "remove_favorite": "Odstrani iz priljubljenih" }, - "zen_mode": { "title": "Zen način", "tooltip": "Zen način (Ctrl+Alt+Z)", "exit": "Izhod iz zen načina", "exit_hint": "Izhod iz zen načina (Esc)" }, - "share": { "button_tooltip": "Deli zapis", "modal_title": "Deli zapis", @@ -153,7 +143,6 @@ "show_qr": "Prikaži QR kodo", "hide_qr": "Skrij QR kodo" }, - "quick_switcher": { "placeholder": "Išči beležke...", "no_results": "Ni rezultatov", @@ -162,7 +151,6 @@ "open": "odpri", "close": "zapri" }, - "tags": { "title": "Oznake", "no_tags": "Ni najdenih oznak", @@ -171,13 +159,11 @@ "clear_all": "Počisti filtre oznak", "filter_by": "Filtriraj po {{tag}} ({{count}} zapiskov)" }, - "outline": { "title": "Oris", "no_headings": "Ni najdenih naslovov", "hint": "Dodajte naslove s sintakso #" }, - "backlinks": { "title": "Povratne povezave", "no_backlinks": "Ni najdenih povratnih povezav", @@ -185,21 +171,18 @@ "select_note": "Izberite beležko za ogled povratnih povezav", "more_refs": "več" }, - "stats": { "words": "besed", "reading_time": "~{{minutes}} min branja", "links": "{{count}} povezav", "click_details": "▼ Kliknite za podrobnosti" }, - "graph": { "title": "Grafični prikaz", "empty": "Ni povezav za prikaz", "markdown_links": "Markdown povezave", "click_hint": "Klik: izberi • Dvojni klik: odpri" }, - "templates": { "title": "Predloge", "select": "Izberite predlogo...", @@ -213,11 +196,9 @@ "available_placeholders": "Razpoložljivi gradniki", "create_note": "Ustvari zapis" }, - "export": { "failed": "Izvoz v HTML ni uspel: {{error}}" }, - "login": { "title": "Prijava", "tagline": "Vaša lastna baza znanja", @@ -226,7 +207,6 @@ "footer": "🔒 Varno in samostojno gostovano", "error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova." }, - "drawing": { "tool_freehand": "Svinčnik — prostoročno", "tool_line": "Ravna črta", @@ -234,23 +214,19 @@ "tool_ellipse": "Krog", "color": "Barva", "width": "Debelina črte", - "saved": "Risba shranjena", "save_hint": "Shrani risbo (Ctrl+S)" }, - "media": { "confirm_delete": "Izbrišem \"{{name}}\"?", "upload_failed": "Nalaganje datoteke ni uspelo", "no_valid_files": "Ni najdenih veljavnih datotek. Podprto: JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "Napaka pri premikanju zapisa.", "failed_folder": "Napaka pri premikanju mape.", "failed_media": "Napaka pri premikanju medijske datoteke." }, - "search": { "previous": "Prejšnje (Shift+F3)", "next": "Naslednje (F3)", @@ -261,22 +237,18 @@ "no_results": "Nobena beležka ne vsebuje \"{{query}}\"", "searching": "Iskanje \"{{query}}\"..." }, - "theme": { "title": "Tema" }, - "language": { "title": "Jezik" }, - "syntax_highlight": { "title": "Poudarjanje sintakse v urejevalniku", "description": "Obarvaj markdown sintakso v urejevalniku", "enable": "Omogoči poudarjanje sintakse", "disable": "Onemogoči poudarjanje sintakse" }, - "settings": { "account": "Račun", "logout": "Odjava", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tipka Tab vstavi tabulator", "tab_inserts_tab_desc": "Pritisnite Tab za vstavljanje tabulatorja namesto premikanja fokusa" }, - "homepage": { "title": "Domov", "welcome": "Dobrodošli v NoteDiscovery", @@ -300,7 +271,6 @@ "folder_singular": "mapa", "folder_plural": "mape" }, - "format": { "bold": "Krepko", "italic": "Ležeče", @@ -316,22 +286,19 @@ "checkbox": "Potrditveno polje", "table": "Tabela" }, - "validation": { "forbidden_chars": "Ime vsebuje prepovedane znake: {{chars}}", "reserved_name": "To ime je rezervirano s strani operacijskega sistema.", "invalid_dot": "Ime ne more biti le pika.", "trailing_dot_space": "Ime se ne more končati s piko ali presledkom." }, - "support": { "enjoying_demo": "Podprite ta projekt", "deploy_own": "Namestite svojo instanco", "thank_you": "Hvala za podporo! 💚" }, - "demo": { "title": "DEMO NAČIN", "warning": "Vsebina se dnevno ponastavi. Spremembe lahko prepišejo drugi uporabniki." } -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0d603f0..2271d24 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -4,11 +4,9 @@ "name": "Chinese (Simplified)", "flag": "🇨🇳" }, - "app": { "tagline": "您的自托管知识库" }, - "common": { "save": "保存", "cancel": "取消", @@ -28,12 +26,10 @@ "copy_to_clipboard": "复制到剪贴板", "failed": "{{action}} 失败。请重试。" }, - "toast": { "region_label": "通知", "dismiss": "关闭" }, - "sidebar": { "title": "文件", "favorites_title": "收藏夹", @@ -72,7 +68,6 @@ "settings_title": "设置", "filtered_notes": "筛选的笔记" }, - "editor": { "placeholder": "开始用 Markdown 写作...", "drop_hint": "💡 拖放到此处以插入到光标位置...", @@ -85,7 +80,6 @@ "hours_ago": "{{count}}小时前", "days_ago": "{{count}}天前" }, - "notes": { "confirm_delete": "删除 \"{{name}}\"?", "already_exists": "此位置已存在名为 \"{{name}}\" 的笔记。\n请选择一个不同的名称。", @@ -101,7 +95,6 @@ "no_content": "没有可导出的笔记内容", "open_first": "请先打开一篇笔记,然后再上传图片。" }, - "folders": { "confirm_delete": "⚠️ 警告 ⚠️\n\n您确定要删除文件夹 \"{{name}}\" 吗?\n\n这将永久删除:\n• 此文件夹内的所有笔记\n• 所有子文件夹及其内容\n\n此操作无法撤销!", "already_exists": "此位置已存在名为 \"{{name}}\" 的文件夹。\n请选择一个不同的名称。", @@ -118,7 +111,6 @@ "cannot_move_into_self": "无法将文件夹移动到其自身或其子文件夹中。", "empty": "空" }, - "toolbar": { "undo": "撤销 (Ctrl+Z)", "redo": "重做 (Ctrl+Y)", @@ -131,14 +123,12 @@ "add_favorite": "添加到收藏夹", "remove_favorite": "从收藏夹移除" }, - "zen_mode": { "title": "禅模式", "tooltip": "禅模式 (Ctrl+Alt+Z)", "exit": "退出禅模式", "exit_hint": "退出禅模式 (Esc)" }, - "share": { "button_tooltip": "分享笔记", "modal_title": "分享笔记", @@ -153,7 +143,6 @@ "show_qr": "显示二维码", "hide_qr": "隐藏二维码" }, - "quick_switcher": { "placeholder": "搜索笔记...", "no_results": "无匹配结果", @@ -162,7 +151,6 @@ "open": "打开", "close": "关闭" }, - "tags": { "title": "标签", "no_tags": "未找到标签", @@ -171,13 +159,11 @@ "clear_all": "清除所有标签过滤器", "filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)" }, - "outline": { "title": "大纲", "no_headings": "未找到标题", "hint": "使用 # 语法添加标题" }, - "backlinks": { "title": "反向链接", "no_backlinks": "未找到反向链接", @@ -185,21 +171,18 @@ "select_note": "选择一个笔记以查看反向链接", "more_refs": "更多" }, - "stats": { "words": "字数", "reading_time": "~{{minutes}}分钟阅读", "links": "{{count}} 个链接", "click_details": "▼ 点击查看详情" }, - "graph": { "title": "图谱", "empty": "无连接可显示", "markdown_links": "Markdown 链接", "click_hint": "点击:选择 • 双击:打开" }, - "templates": { "title": "模板", "select": "选择一个模板...", @@ -213,11 +196,9 @@ "available_placeholders": "可用占位符", "create_note": "创建笔记" }, - "export": { "failed": "导出 HTML 失败:{{error}}" }, - "login": { "title": "登录", "tagline": "您的自托管知识库", @@ -226,7 +207,6 @@ "footer": "🔒 安全且自托管", "error_incorrect_password": "密码错误。请重试。" }, - "drawing": { "tool_freehand": "铅笔 — 自由绘制", "tool_line": "直线", @@ -234,23 +214,19 @@ "tool_ellipse": "圆形", "color": "颜色", "width": "线条粗细", - "saved": "绘图已保存", "save_hint": "保存绘图(Ctrl+S)" }, - "media": { "confirm_delete": "删除 \"{{name}}\"?", "upload_failed": "上传文件失败", "no_valid_files": "未找到有效文件。支持:JPG, PNG, GIF, WebP, MP3, MP4, PDF", "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF" }, - "move": { "failed_note": "移动笔记失败。", "failed_folder": "移动文件夹失败。", "failed_media": "移动媒体文件失败。" }, - "search": { "previous": "上一个 (Shift+F3)", "next": "下一个 (F3)", @@ -261,22 +237,18 @@ "no_results": "没有笔记包含\"{{query}}\"", "searching": "正在搜索\"{{query}}\"..." }, - "theme": { "title": "主题" }, - "language": { "title": "语言" }, - "syntax_highlight": { "title": "编辑器语法高亮", "description": "在编辑器中为 Markdown 语法着色", "enable": "启用语法高亮", "disable": "禁用语法高亮" }, - "settings": { "account": "账户", "logout": "登出", @@ -287,7 +259,6 @@ "tab_inserts_tab": "Tab键插入制表符", "tab_inserts_tab_desc": "按Tab键插入制表符而不是切换焦点" }, - "homepage": { "title": "首页", "welcome": "欢迎使用 NoteDiscovery", @@ -300,7 +271,6 @@ "folder_singular": "文件夹", "folder_plural": "文件夹" }, - "format": { "bold": "粗体", "italic": "斜体", @@ -316,23 +286,19 @@ "checkbox": "复选框", "table": "表格" }, - "validation": { "forbidden_chars": "名称包含禁止的字符:{{chars}}", "reserved_name": "此名称被操作系统保留。", "invalid_dot": "名称不能只是一个点。", "trailing_dot_space": "名称不能以点或空格结尾。" }, - "support": { "enjoying_demo": "支持此项目", "deploy_own": "部署您自己的实例", "thank_you": "感谢您的支持!💚" }, - "demo": { "title": "演示模式", "warning": "内容每日重置。您的更改可能被其他用户覆盖。" } } - From 7edbc28a9cf678bb12f9d8e33ff473a39ff05747 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 17:39:22 +0200 Subject: [PATCH 05/15] added more tools, fixed locales --- frontend/app.js | 85 ++++++++++++++++++++++++++++++++++++++++++--- frontend/index.html | 84 +++++++++++++++++++++++++++++++++++--------- locales/de-DE.json | 7 +++- locales/en-GB.json | 7 +++- locales/en-US.json | 7 +++- locales/es-ES.json | 7 +++- locales/fr-FR.json | 7 +++- locales/hu-HU.json | 7 +++- locales/it-IT.json | 7 +++- locales/ja-JP.json | 7 +++- locales/ru-RU.json | 7 +++- locales/sl-SI.json | 7 +++- locales/zh-CN.json | 7 +++- 13 files changed, 214 insertions(+), 32 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 39bf836..81c7aa1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3,6 +3,8 @@ // Configuration constants const CONFIG = { AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave) + /** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */ + DRAWING_BACKGROUND: '#ffffff', SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference @@ -504,6 +506,8 @@ function noteApp() { drawingRedoStack: [], drawingDraft: null, drawingIsPointerDown: false, + /** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */ + drawingHasRasterFromFile: false, _drawingAutosaveTimeout: null, // DOM element cache (to avoid repeated querySelector calls) @@ -2904,7 +2908,7 @@ function noteApp() { const dpr = this._drawingDpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = CONFIG.DRAWING_BACKGROUND; ctx.fillRect(0, 0, w, h); if (this._drawingBaseImage && this._drawingBaseImage.complete) { ctx.drawImage(this._drawingBaseImage, 0, 0, w, h); @@ -3016,6 +3020,7 @@ function noteApp() { this.drawingDraft = null; this.drawingIsPointerDown = false; this._drawingBaseImage = null; + this.drawingHasRasterFromFile = false; this._drawingLoadToken = Symbol(); const token = this._drawingLoadToken; @@ -3050,28 +3055,98 @@ function noteApp() { }); if (token !== this._drawingLoadToken) return; this._drawingBaseImage = img; + this.drawingHasRasterFromFile = true; this._drawingLayoutCanvas(); } catch (e) { if (token !== this._drawingLoadToken) return; ErrorHandler.handle('load drawing', e); + this.drawingHasRasterFromFile = false; this._drawingLayoutCanvas(); } }, + _drawingRgbToHex(r, g, b) { + const h = (n) => { + const s = n.toString(16); + return s.length === 1 ? `0${s}` : s; + }; + return `#${h(r)}${h(g)}${h(b)}`; + }, + + /** Sample visible canvas color at logical (css) coordinates; sets drawingColor. */ + drawingSampleColor(e) { + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + const { x, y } = this._drawingCanvasCoords(e); + this.drawingRedraw(); + const dpr = this._drawingDpr || 1; + let ix = Math.floor(x * dpr); + let iy = Math.floor(y * dpr); + ix = Math.max(0, Math.min(ix, canvas.width - 1)); + iy = Math.max(0, Math.min(iy, canvas.height - 1)); + const pix = ctx.getImageData(ix, iy, 1, 1).data; + this.drawingColor = this._drawingRgbToHex(pix[0], pix[1], pix[2]); + }, + + /** True when there is a loaded bitmap and/or session strokes to clear away. */ + drawingClearEnabled() { + if (this.currentMediaType !== 'drawing') return false; + return !!(this.drawingHasRasterFromFile || (this.drawingOps && this.drawingOps.length > 0)); + }, + + /** + * Replace the in-memory drawing with a blank canvas and schedule save so the file on disk + * becomes a fresh white PNG (same dimensions as the viewer). Drops the loaded raster. + */ + async drawingClear() { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + if (!this._drawingBaseImage && this.drawingOps.length === 0) return; + const ok = await this.confirmModalAsk({ + title: this.t('drawing.clear_title'), + message: this.t('drawing.clear_confirm'), + danger: true, + confirmLabel: this.t('drawing.clear'), + }); + if (!ok) return; + this._drawingCancelAutosave(); + if (this._drawingObjectURL) { + try { + URL.revokeObjectURL(this._drawingObjectURL); + } catch (_) { + /* ignore */ + } + this._drawingObjectURL = null; + } + this._drawingBaseImage = null; + this.drawingHasRasterFromFile = false; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this.drawingOps = []; + this.drawingRedoStack = []; + this.drawingRedraw(); + this._drawingScheduleAutosave(); + }, + drawingPointerDown(e) { if (this.currentMediaType !== 'drawing' || e.button !== 0) return; const canvas = this._drawingCanvasEl; if (!canvas) return; + const tool = this.drawingTool; + if (tool === 'eyedropper') { + e.preventDefault(); + this.drawingSampleColor(e); + return; + } this._drawingCancelAutosave(); canvas.setPointerCapture(e.pointerId); this._drawingPointerId = e.pointerId; const { x, y } = this._drawingCanvasCoords(e); this.drawingIsPointerDown = true; this.drawingRedoStack = []; - const color = this.drawingColor; const lw = this.drawingLineWidth; - const tool = this.drawingTool; - if (tool === 'freehand') { + const color = tool === 'eraser' ? CONFIG.DRAWING_BACKGROUND : this.drawingColor; + if (tool === 'freehand' || tool === 'eraser') { this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] }; } else if (tool === 'line') { this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y }; @@ -3187,7 +3262,7 @@ function noteApp() { }, /** - * Persist the flattened canvas to disk (Ctrl+S, toolbar, autosave). Same feedback as saveNote: + * Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote: * header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when * opening another drawing via initDrawingViewer(). */ diff --git a/frontend/index.html b/frontend/index.html index 4cbb8b9..ee2a0eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2705,27 +2705,77 @@ -
- - + + + +
+ + + +
@@ -3011,7 +3061,9 @@ Date: Tue, 21 Apr 2026 18:00:50 +0200 Subject: [PATCH 06/15] refactor and limits change --- backend/main.py | 6 +++- frontend/app.js | 85 ++++++++++++++++++++++++++++++------------------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/backend/main.py b/backend/main.py index cff914b..bcf5ff2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -626,7 +626,7 @@ async def get_media(media_path: str): @api_router.put("/media/{media_path:path}", tags=["Media"]) -@limiter.limit("30/minute") +@limiter.limit("120/minute") async def put_media(media_path: str, request: Request): """ Overwrite an existing media file in place (used for saving drawing PNGs). @@ -662,6 +662,10 @@ async def put_media(media_path: str, request: Request): detail=f"File too large. Maximum size: {UPLOAD_MAX_IMAGE_MB}MB", ) + # Reject non-PNG payloads (defense in depth; path already restricts to .png) + if len(body) < 8 or body[:8] != b"\x89PNG\r\n\x1a\n": + raise HTTPException(status_code=400, detail="Body must be a valid PNG image") + try: with open(full_path, 'wb') as f: f.write(body) diff --git a/frontend/app.js b/frontend/app.js index 81c7aa1..a70d0a9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -508,6 +508,10 @@ function noteApp() { drawingIsPointerDown: false, /** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */ drawingHasRasterFromFile: false, + /** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */ + _drawingSaveInFlight: false, + /** If true, run drawingSave again after the current one finishes (coalesce). */ + _drawingSaveQueued: false, _drawingAutosaveTimeout: null, // DOM element cache (to avoid repeated querySelector calls) @@ -3268,43 +3272,60 @@ function noteApp() { */ async drawingSave() { if (!this.currentMedia || this.currentMediaType !== 'drawing') return; - this._drawingCancelAutosave(); - const canvas = this._drawingCanvasEl; - const ctx = this._drawingCtx; - if (!canvas || !ctx) return; - this.drawingDraft = null; - this.drawingIsPointerDown = false; - this.drawingRedraw(); - await new Promise((r) => requestAnimationFrame(r)); - const blob = await new Promise((resolve, reject) => { - canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); - }); - this.isSaving = true; + if (this._drawingSaveInFlight) { + this._drawingSaveQueued = true; + return; + } + this._drawingSaveInFlight = true; try { - const enc = this._drawingEncodeMediaPath(); - const res = await fetch(`/api/media/${enc}`, { - method: 'PUT', - body: blob, - headers: { 'Content-Type': 'image/png' }, - credentials: 'same-origin', + this._drawingCancelAutosave(); + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this.drawingRedraw(); + await new Promise((r) => requestAnimationFrame(r)); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - let detail = err.detail; - if (Array.isArray(detail)) { - detail = detail.map((d) => d.msg || d).join(', '); + this.isSaving = true; + try { + const enc = this._drawingEncodeMediaPath(); + const res = await fetch(`/api/media/${enc}`, { + method: 'PUT', + body: blob, + headers: { 'Content-Type': 'image/png' }, + credentials: 'same-origin', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + let detail = err.detail; + if (Array.isArray(detail)) { + detail = detail.map((d) => d.msg || d).join(', '); + } + throw new Error(detail || res.statusText); } - throw new Error(detail || res.statusText); + await this.loadNotes(); + this.lastSaved = true; + setTimeout(() => { + this.lastSaved = false; + }, CONFIG.SAVE_INDICATOR_DURATION); + } catch (error) { + ErrorHandler.handle('save drawing', error); + } finally { + this.isSaving = false; } - await this.loadNotes(); - this.lastSaved = true; - setTimeout(() => { - this.lastSaved = false; - }, CONFIG.SAVE_INDICATOR_DURATION); - } catch (error) { - ErrorHandler.handle('save drawing', error); } finally { - this.isSaving = false; + this._drawingSaveInFlight = false; + if (this._drawingSaveQueued) { + this._drawingSaveQueued = false; + queueMicrotask(() => { + if (this.currentMedia && this.currentMediaType === 'drawing') { + this.drawingSave(); + } + }); + } } }, From 1b81d1e3bc60fe7e48a62d34166f7ac8359b55e6 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 18:12:01 +0200 Subject: [PATCH 07/15] doc updates --- README.md | 12 ++++++++++++ docs/index.html | 23 +++++++++++++++-------- documentation/API.md | 25 +++++++++++++++++++++++++ documentation/DRAWING.md | 29 +++++++++++++++++++++++++++++ documentation/FEATURES.md | 7 ++++--- 5 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 documentation/DRAWING.md diff --git a/README.md b/README.md index 3872bf7..01efef8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,17 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - 📑 **Outline Panel** - Navigate headings with click-to-jump TOC - 🤖 **AI Assistant Ready** - MCP integration for Claude, Cursor & more +## ✏️ Drawing editor + +NoteDiscovery isn’t only markdown—you can **sketch and annotate** without leaving the app. Create a **New drawing** from the sidebar; the app saves a `drawing-{timestamp}.png` next to your notes, same as any other file in your vault. + +- **Real tools** — Freehand pencil, straight lines, rectangles, ellipses, **eraser** (background-color stroke), **eyedropper** to sample colors from the canvas, and **clear** to replace the image with a blank canvas (with confirmation). +- **Comfortable controls** — Color picker and stroke width on the drawing toolbar; **undo/redo** for strokes (same shortcuts as the editor when a drawing is open). +- **Saves like everything else** — **Autosave** after you finish a stroke, plus **Ctrl+S** / **Cmd+S** to flush the PNG to disk immediately. +- **Plain PNG** — Drawings are normal images on disk, so previews, export, and backups work the same as for screenshots or diagrams. + +> 📖 **Full details:** [DRAWING.md](documentation/DRAWING.md) · Listed in [FEATURES.md](documentation/FEATURES.md) + ## 🤖 AI-Powered Note Management

@@ -240,6 +251,7 @@ Want to learn more? - 🎨 **[THEMES.md](documentation/THEMES.md)** - Theme customization and creating custom themes - ✨ **[FEATURES.md](documentation/FEATURES.md)** - Complete feature list and keyboard shortcuts +- ✏️ **[DRAWING.md](documentation/DRAWING.md)** - Built-in drawing editor (`drawing-*.png`), save behavior, and API notes - 🏷️ **[TAGS.md](documentation/TAGS.md)** - Organize notes with tags and combined filtering - 📋 **[TEMPLATES.md](documentation/TEMPLATES.md)** - Create notes from reusable templates with dynamic placeholders - 🧮 **[MATHJAX.md](documentation/MATHJAX.md)** - LaTeX/Math notation examples and syntax reference diff --git a/docs/index.html b/docs/index.html index def810f..aea91dd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,8 +7,8 @@ NoteDiscovery - Your Self-Hosted Knowledge Base - - + + @@ -17,7 +17,7 @@ - + @@ -28,7 +28,7 @@ - + @@ -353,7 +353,7 @@ animation: fadeInScale 0.6s ease-out forwards; } - /* Stagger animation delays for feature cards (5 rows × 3 cols = 15 cards) */ + /* Stagger animation delays for feature cards (16 cards) */ .feature.animated:nth-child(1) { animation-delay: 0s; } .feature.animated:nth-child(2) { animation-delay: 0.1s; } .feature.animated:nth-child(3) { animation-delay: 0.2s; } @@ -369,6 +369,7 @@ .feature.animated:nth-child(13) { animation-delay: 0.2s; } .feature.animated:nth-child(14) { animation-delay: 0.3s; } .feature.animated:nth-child(15) { animation-delay: 0.4s; } + .feature.animated:nth-child(16) { animation-delay: 0.45s; } .screenshot-section { margin: 4rem 0 3rem 0; @@ -689,7 +690,7 @@

Take Control of Your Notes

-

A lightweight, privacy-focused note-taking application with powerful features: AI assistant integration (MCP), LaTeX math equations, Mermaid diagrams, smart tags, custom templates, code highlighting, and more. Write and organize your notes with a beautiful, modern interface all running on your own server.

+

A lightweight, privacy-focused note-taking application with powerful features: AI assistant integration (MCP), LaTeX math equations, Mermaid diagrams, an embedded drawing editor for quick sketches, smart tags, custom templates, code highlighting, and more. Write and organize your notes with a beautiful, modern interface all running on your own server.

Try Live Demo Get Started on GitHub → @@ -858,6 +859,12 @@

Create flowcharts, mind maps, and diagrams with Mermaid syntax.

+
+
✏️
+

Drawing editor

+

Sketch and annotate as PNG files next to your notes—autosave, shapes, eraser, and eyedropper.

+
+
💻

Code Highlighting

@@ -1191,11 +1198,11 @@ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "NoteDiscovery", - "description": "A lightweight, AI-powered Markdown note-taking application with MCP integration for Claude, Cursor and other AI assistants. Features LaTeX math, Mermaid diagrams, graph view, and code highlighting. Self-hosted Obsidian and Evernote alternative. Free and open source.", + "description": "A lightweight, AI-powered Markdown note-taking application with MCP integration for Claude, Cursor and other AI assistants. Features LaTeX math, Mermaid diagrams, drawing editor, graph view, and code highlighting. Self-hosted Obsidian and Evernote alternative. Free and open source.", "url": "https://www.notediscovery.com", "applicationCategory": "ProductivityApplication", "operatingSystem": "Linux, Windows, macOS", - "featureList": "AI assistant integration, MCP (Model Context Protocol), Claude integration, Cursor integration, Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian alternative, Evernote alternative, Notion alternative, Second brain, AI-powered notes", + "featureList": "AI assistant integration, MCP (Model Context Protocol), Claude integration, Cursor integration, Markdown editor, Wikilinks, Graph view, Drawing editor, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian alternative, Evernote alternative, Notion alternative, Second brain, AI-powered notes", "offers": { "@type": "Offer", "price": "0", diff --git a/documentation/API.md b/documentation/API.md index 90fbef5..cbb995e 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -214,6 +214,31 @@ curl http://localhost:8000/api/media/folder/_attachments/image-20240417093343.pn - The file exists and is a valid media format - The requesting user is authenticated (if auth is enabled) +### Update drawing (PNG in place) + +```http +PUT /api/media/{media_path} +Content-Type: image/png +``` + +Overwrites an **existing** file in the vault. The server only accepts targets whose filename matches **`drawing-*.png`** (lowercase `.png`). The body must be a valid **PNG** (magic bytes are checked). Used by the in-app drawing editor when saving. + +**Requirements:** +- File must already exist (create new drawings via **New drawing** / `POST /api/upload-media` with `next_to_notes`, not via PUT). +- Path must stay within the notes directory (same rules as GET). + +**Response:** +```json +{ "success": true, "path": "folder/drawing-20260101120000.png" } +``` + +**Example:** +```bash +curl -X PUT http://localhost:8000/api/media/myproject/drawing-20260101120000.png \ + -H "Content-Type: image/png" \ + --data-binary @sketch.png +``` + ### Upload Media ```http POST /api/upload-media diff --git a/documentation/DRAWING.md b/documentation/DRAWING.md new file mode 100644 index 0000000..d18809e --- /dev/null +++ b/documentation/DRAWING.md @@ -0,0 +1,29 @@ +# Drawing editor + +NoteDiscovery includes a **built-in drawing editor** for sketching and annotating directly in the app. Drawings are stored as ordinary **PNG files** in your vault, so they work like any other image: previews, export, and backups behave the same. + +## Creating a drawing + +Use the **+ New** menu in the sidebar and choose **New drawing**. The app creates a file named `drawing-{timestamp}.png` next to your notes (in the folder you pick), then opens it in the drawing viewer. + +## Editor overview + +- **Tools** — Freehand pencil, straight line, rectangle, and ellipse; **eraser** (paints with the canvas background color); **eyedropper** to sample a color from the canvas. +- **Color & stroke width** — Color picker and width slider appear on the same toolbar as the tools. +- **Undo / redo** — Use the main toolbar buttons or **Ctrl+Z** / **Ctrl+Y** (same shortcuts as the note editor; they apply to strokes while a drawing is open). +- **Clear** — Replaces the current session with a **blank white image** and schedules a save (see [FEATURES.md](FEATURES.md) for the exact behavior and confirmation). +- **Saving** — Changes are saved automatically after you finish a stroke (debounced), and you can press **Ctrl+S** (Cmd+S on Mac) to save the PNG immediately. + +## Files on disk + +- Only files whose names match **`drawing-*.png`** are opened in the drawing editor; other PNGs open as normal images. +- The saved PNG’s **pixel dimensions** match the drawing pane at save time (layout and device pixel ratio), similar to a screenshot of the canvas area. + +## API + +To update an existing drawing from automation, use **PUT `/api/media/{path}`** with a **PNG body**; the server only allows in-place updates for `drawing-*.png` paths. See [API.md](API.md#update-drawing-png-in-place). + +## See also + +- [FEATURES.md](FEATURES.md) — Full feature list and keyboard shortcuts +- [API.md](API.md) — Media endpoints diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 7cba70a..c25b246 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -15,6 +15,7 @@ - **Public Sharing** - Share notes via token-based URLs with optional QR code for mobile (see [SHARING.md](SHARING.md)) ### Media Support +- **Drawing editor** - Create and edit **`drawing-*.png`** sketches next to your notes (pencil, shapes, eraser, eyedropper, autosave); see [DRAWING.md](DRAWING.md) - **Drag & drop upload** - Drop files from your file system directly into the editor - **Clipboard paste** - Paste images from clipboard with Ctrl+V - **Images** - JPG, PNG, GIF, WebP (default max 10MB, configurable) @@ -311,11 +312,11 @@ date: {{date}} | Windows/Linux | Mac | Action | |---------------|-----|--------| | `Ctrl+Alt+P` | `Cmd+Option+P` | Quick Switcher (jump to any note) | -| `Ctrl+S` | `Cmd+S` | Save note | +| `Ctrl+S` | `Cmd+S` | Save note (or **save drawing PNG** when a `drawing-*.png` is open) | | `Ctrl+Alt+N` | `Cmd+Option+N` | New note | | `Ctrl+Alt+F` | `Cmd+Option+F` | New folder | -| `Ctrl+Z` | `Cmd+Z` | Undo | -| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | +| `Ctrl+Z` | `Cmd+Z` | Undo (note edits, or **drawing strokes** when a drawing is open) | +| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo (note edits, or **drawing strokes** when a drawing is open) | | `Ctrl+Alt+Z` | `Cmd+Option+Z` | Toggle Zen Mode | | `Esc` | `Esc` | Exit Zen Mode | | `F3` | `F3` | Next search match | From 1d1cad4e356235ee3b2a603bb4c1e087c89c1917 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 18:14:25 +0200 Subject: [PATCH 08/15] doc improvements --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 01efef8..0d3acd8 100644 --- a/README.md +++ b/README.md @@ -64,20 +64,25 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - 🧮 **Math Support** - LaTeX/MathJax for beautiful equations - 📄 **HTML Export & Print** - Export notes as standalone HTML or print - 🕸️ **Graph View** - Interactive visualization of connected notes +- ✏️ **Drawing editor** - In-app canvas for sketches and annotations saved as `drawing-*.png` — **see the next section** - ⭐ **Favorites** - Star your most-used notes for instant access - 📑 **Outline Panel** - Navigate headings with click-to-jump TOC - 🤖 **AI Assistant Ready** - MCP integration for Claude, Cursor & more ## ✏️ Drawing editor -NoteDiscovery isn’t only markdown—you can **sketch and annotate** without leaving the app. Create a **New drawing** from the sidebar; the app saves a `drawing-{timestamp}.png` next to your notes, same as any other file in your vault. +NoteDiscovery isn’t only markdown—you can **sketch, diagram, and mark up ideas** without a separate app. Use **+ New → New drawing** in the sidebar: the app creates a **`drawing-{timestamp}.png`** in the folder you choose, next to your `.md` files. Open it from the tree like any image; files named `drawing-*.png` open in the **drawing viewer** instead of the static image viewer. -- **Real tools** — Freehand pencil, straight lines, rectangles, ellipses, **eraser** (background-color stroke), **eyedropper** to sample colors from the canvas, and **clear** to replace the image with a blank canvas (with confirmation). -- **Comfortable controls** — Color picker and stroke width on the drawing toolbar; **undo/redo** for strokes (same shortcuts as the editor when a drawing is open). -- **Saves like everything else** — **Autosave** after you finish a stroke, plus **Ctrl+S** / **Cmd+S** to flush the PNG to disk immediately. -- **Plain PNG** — Drawings are normal images on disk, so previews, export, and backups work the same as for screenshots or diagrams. +**What you get** -> 📖 **Full details:** [DRAWING.md](documentation/DRAWING.md) · Listed in [FEATURES.md](documentation/FEATURES.md) +| | | +|--|--| +| **Tools** | Pencil, line, rectangle, ellipse, **eraser** (paints with the canvas background), **eyedropper** (sample color from the pixel under the cursor), **clear** (replace with a blank white image—confirmed, then saved). | +| **Toolbar** | Color picker and stroke width next to the tools; main bar still has **undo/redo** for strokes when the drawing is focused. | +| **Saving** | **Autosave** shortly after you release the pointer from a stroke; **Ctrl+S** / **Cmd+S** saves the PNG immediately (same key as saving a note). | +| **On disk** | Ordinary PNG files—embed in notes with `![]()`, version in Git, back up with the rest of your vault. | + +> 📖 **Reference:** [documentation/DRAWING.md](documentation/DRAWING.md) · Also covered in [FEATURES.md](documentation/FEATURES.md) ## 🤖 AI-Powered Note Management From 0922af3c644460d1425c62d24a1b965c38be04fc Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 18:20:07 +0200 Subject: [PATCH 09/15] fixed landing page --- docs/index.html | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/index.html b/docs/index.html index aea91dd..850facf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -353,7 +353,7 @@ animation: fadeInScale 0.6s ease-out forwards; } - /* Stagger animation delays for feature cards (16 cards) */ + /* Stagger animation delays for feature cards (15 cards) */ .feature.animated:nth-child(1) { animation-delay: 0s; } .feature.animated:nth-child(2) { animation-delay: 0.1s; } .feature.animated:nth-child(3) { animation-delay: 0.2s; } @@ -369,7 +369,6 @@ .feature.animated:nth-child(13) { animation-delay: 0.2s; } .feature.animated:nth-child(14) { animation-delay: 0.3s; } .feature.animated:nth-child(15) { animation-delay: 0.4s; } - .feature.animated:nth-child(16) { animation-delay: 0.45s; } .screenshot-section { margin: 4rem 0 3rem 0; @@ -901,12 +900,6 @@

Several built-in themes with dark/light modes and full customization.

-
-
-

Favorites

-

Star your most-used notes for instant access from the sidebar.

-
-
📑

Outline Panel

From 59c510f1932291f54937f0b2be54efcca144b1c0 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 18:27:54 +0200 Subject: [PATCH 10/15] added more docs --- README.md | 17 +---------------- documentation/FEATURES.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0d3acd8..611bd87 100644 --- a/README.md +++ b/README.md @@ -64,26 +64,11 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - 🧮 **Math Support** - LaTeX/MathJax for beautiful equations - 📄 **HTML Export & Print** - Export notes as standalone HTML or print - 🕸️ **Graph View** - Interactive visualization of connected notes -- ✏️ **Drawing editor** - In-app canvas for sketches and annotations saved as `drawing-*.png` — **see the next section** +- ✏️ **Drawing editor** - In-app sketches as `drawing-*.png` next to your notes — see [documentation/DRAWING.md](documentation/DRAWING.md) - ⭐ **Favorites** - Star your most-used notes for instant access - 📑 **Outline Panel** - Navigate headings with click-to-jump TOC - 🤖 **AI Assistant Ready** - MCP integration for Claude, Cursor & more -## ✏️ Drawing editor - -NoteDiscovery isn’t only markdown—you can **sketch, diagram, and mark up ideas** without a separate app. Use **+ New → New drawing** in the sidebar: the app creates a **`drawing-{timestamp}.png`** in the folder you choose, next to your `.md` files. Open it from the tree like any image; files named `drawing-*.png` open in the **drawing viewer** instead of the static image viewer. - -**What you get** - -| | | -|--|--| -| **Tools** | Pencil, line, rectangle, ellipse, **eraser** (paints with the canvas background), **eyedropper** (sample color from the pixel under the cursor), **clear** (replace with a blank white image—confirmed, then saved). | -| **Toolbar** | Color picker and stroke width next to the tools; main bar still has **undo/redo** for strokes when the drawing is focused. | -| **Saving** | **Autosave** shortly after you release the pointer from a stroke; **Ctrl+S** / **Cmd+S** saves the PNG immediately (same key as saving a note). | -| **On disk** | Ordinary PNG files—embed in notes with `![]()`, version in Git, back up with the rest of your vault. | - -> 📖 **Reference:** [documentation/DRAWING.md](documentation/DRAWING.md) · Also covered in [FEATURES.md](documentation/FEATURES.md) - ## 🤖 AI-Powered Note Management

diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index c25b246..b963166 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -15,7 +15,7 @@ - **Public Sharing** - Share notes via token-based URLs with optional QR code for mobile (see [SHARING.md](SHARING.md)) ### Media Support -- **Drawing editor** - Create and edit **`drawing-*.png`** sketches next to your notes (pencil, shapes, eraser, eyedropper, autosave); see [DRAWING.md](DRAWING.md) +- **Drawing editor** — In-app **`drawing-*.png`** sketches next to your notes ([overview](#drawing-editor)); full guide: **[DRAWING.md](DRAWING.md)** - **Drag & drop upload** - Drop files from your file system directly into the editor - **Clipboard paste** - Paste images from clipboard with Ctrl+V - **Images** - JPG, PNG, GIF, WebP (default max 10MB, configurable) @@ -43,6 +43,16 @@ - **Theme-aware** - Export uses your current theme for consistent appearance - **Full rendering** - MathJax equations, Mermaid diagrams, and syntax highlighting included +## ✏️ Drawing editor + +Sketch beside your notes without leaving NoteDiscovery. **+ New → New drawing** creates a **`drawing-{timestamp}.png`** next to your markdown files; those open in the drawing viewer, other images open as usual. + +- **Tools** — Pencil, lines, rectangles, ellipses, eraser, eyedropper, clear; color and stroke width on the toolbar; undo/redo while you work. +- **Saving** — Autosave and **Ctrl+S** / **Cmd+S** like the rest of the app. +- **Files** — Plain PNGs in your vault—link them in notes and back them up with everything else. + +For file naming, API notes, and more: **[DRAWING.md](DRAWING.md)** + ## 🔗 Linking & Discovery ### Graph View From 4061633daaf1f9104018fad6ee1aca78f98af79f Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Wed, 22 Apr 2026 15:20:59 +0200 Subject: [PATCH 11/15] prune shared files not found --- backend/share.py | 60 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/backend/share.py b/backend/share.py index c4198b5..01b79ff 100644 --- a/backend/share.py +++ b/backend/share.py @@ -11,10 +11,56 @@ from datetime import datetime, timezone from typing import Optional, Dict, Any import threading +from .utils import validate_path_security + # Thread lock for safe concurrent access _lock = threading.Lock() +def _token_references_accessible_file(storage_dir: str, rel_path: Any) -> bool: + """ + Return True if rel_path is a string that resolves to a regular file under storage_dir + and passes the same path boundary check used elsewhere in the app. + """ + if not rel_path or not isinstance(rel_path, str): + return False + try: + full = (Path(storage_dir) / rel_path).resolve() + except (OSError, ValueError, RuntimeError): + return False + if not validate_path_security(str(storage_dir), full): + return False + return full.is_file() + + +def _prune_inaccessible_unsafe(data_dir: str) -> int: + """ + Remove share tokens whose stored path is missing or not under the notes directory. + Call only while holding _lock. Returns the number of tokens removed. + """ + tokens = load_tokens(data_dir) + if not tokens: + return 0 + kept: Dict[str, Dict[str, Any]] = {} + for token, info in tokens.items(): + path = info.get("path") + if _token_references_accessible_file(data_dir, path): + kept[token] = info + removed = len(tokens) - len(kept) + if removed and not save_tokens(data_dir, kept): + return 0 + return removed + + +def prune_inaccessible_share_tokens(data_dir: str) -> int: + """ + Remove entries whose path no longer resolves to a file under the storage root. + Called automatically after create/revoke share; may also be used from tests or one-off maintenance. + """ + with _lock: + return _prune_inaccessible_unsafe(data_dir) + + def generate_token(length: int = 16) -> str: """Generate a URL-safe random token.""" # Use alphanumeric + underscore/hyphen (URL-safe) @@ -76,8 +122,9 @@ def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> O # Check if note already has a token for token, info in tokens.items(): if info.get('path') == note_path: + _prune_inaccessible_unsafe(data_dir) return token - + # Generate new token token = generate_token() @@ -93,7 +140,9 @@ def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> O } if save_tokens(data_dir, tokens): + _prune_inaccessible_unsafe(data_dir) return token + _prune_inaccessible_unsafe(data_dir) return None @@ -177,8 +226,13 @@ def revoke_share_token(data_dir: str, note_path: str) -> bool: if token_to_remove: del tokens[token_to_remove] - return save_tokens(data_dir, tokens) - + if not save_tokens(data_dir, tokens): + _prune_inaccessible_unsafe(data_dir) + return False + _prune_inaccessible_unsafe(data_dir) + return True + + _prune_inaccessible_unsafe(data_dir) return False From 142a3a59cd240fb7d3c13f9def68b26401a5daf8 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Wed, 22 Apr 2026 15:38:16 +0200 Subject: [PATCH 12/15] added display of currently shared notes --- frontend/app.js | 31 ++++++++++++++++++++--- frontend/index.html | 62 +++++++++++++++++++++++++++++++++++++++++++++ locales/de-DE.json | 4 ++- locales/en-GB.json | 4 ++- locales/en-US.json | 4 ++- locales/es-ES.json | 4 ++- locales/fr-FR.json | 4 ++- locales/hu-HU.json | 4 ++- locales/it-IT.json | 4 ++- locales/ja-JP.json | 4 ++- locales/ru-RU.json | 4 ++- locales/sl-SI.json | 4 ++- locales/zh-CN.json | 4 ++- 13 files changed, 123 insertions(+), 14 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index a70d0a9..c5a3713 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -19,6 +19,9 @@ const CONFIG = { TOAST_DURATION_SUCCESS_MS: 3500, }; +/** Heroicons outline "share" (same d= as shared-note icon in the file tree) */ +const SHARE_ICON_PATH = 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z'; + // localStorage settings configuration - centralized definition of all persisted settings const LOCAL_SETTINGS = { // Boolean settings @@ -231,7 +234,7 @@ function noteApp() { sortMode: localStorage.getItem('sortMode') || 'a-z', // Icon rail / panel state - activePanel: 'files', // 'files', 'search', 'tags', 'settings' + activePanel: 'files', // 'files', 'search', 'tags', 'outline', 'backlinks', 'shared', 'settings' // Folder state folderTree: [], @@ -329,6 +332,7 @@ function noteApp() { showShareQR: false, shareLinkCopied: false, _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators + _sharedNotePathsList: [], // sorted paths, mirrors Set for reactive sidebar panel // Quick Switcher state (Ctrl+Alt+P) showQuickSwitcher: false, @@ -2237,7 +2241,7 @@ function noteApp() { // Share icon for shared notes const isShared = !isMediaFile && this.isNoteShared(note.path); - const shareIcon = isShared ? '' : ''; + const shareIcon = isShared ? `` : ''; const icon = this.getMediaIcon(note.type); const deleteTitle = !isMediaFile ? this.t('toolbar.delete_note') @@ -6111,19 +6115,38 @@ function noteApp() { // Share Functions // ============================================================================ - // Load list of shared note paths (for visual indicators) + // Load list of shared note paths (for visual indicators and Shared sidebar panel) async loadSharedNotePaths() { try { const response = await fetch('/api/shared-notes'); if (response.ok) { const data = await response.json(); this._sharedNotePaths = new Set(data.paths || []); + this._syncSharedNotePathsList(); } } catch (error) { console.error('Failed to load shared note paths:', error); this._sharedNotePaths = new Set(); + this._sharedNotePathsList = []; } }, + + _syncSharedNotePathsList() { + this._sharedNotePathsList = [...this._sharedNotePaths].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) + ); + }, + + /** Display rows for Shared notes panel: name + folder line (search-style) */ + getSharedPanelItems() { + return this._sharedNotePathsList.map((path) => { + const noMd = path.replace(/\.md$/i, ''); + const last = Math.max(noMd.lastIndexOf('/'), noMd.lastIndexOf('\\')); + const name = last < 0 ? noMd : noMd.slice(last + 1); + const folder = last < 0 ? '' : noMd.slice(0, last).replace(/\\/g, '/'); + return { path, name, folder: folder || 'Root' }; + }); + }, // Check if a note is currently shared (O(1) lookup) isNoteShared(notePath) { @@ -6284,6 +6307,7 @@ function noteApp() { this.shareInfo.shared = true; // Update the shared paths set this._sharedNotePaths.add(this.currentNote); + this._syncSharedNotePathsList(); } else { const error = await response.json(); this.toast(this.t('share.error_creating', { error: error.detail || 'Unknown error' }), { type: 'error' }); @@ -6342,6 +6366,7 @@ function noteApp() { this.shareInfo = { shared: false }; // Update the shared paths set this._sharedNotePaths.delete(this.currentNote); + this._syncSharedNotePathsList(); } else { const error = await response.json(); this.toast(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }), { type: 'error' }); diff --git a/frontend/index.html b/frontend/index.html index ee2a0eb..91c3107 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1521,6 +1521,26 @@ x-text="backlinks.length > 9 ? '9+' : backlinks.length" > + + +

@@ -1992,6 +2012,48 @@
+ +
+
+ + +
+
+
+ + +
+
+
+ +