Merge pull request #110 from gamosoft/features/table-of-contents

Features/table of contents
This commit is contained in:
Guillermo Villar 2025-12-17 10:37:59 +01:00 committed by GitHub
commit 1a05bf3968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 591 additions and 64 deletions

View File

@ -62,6 +62,7 @@ If this project has been useful to you, consider supporting its development, it
- 📄 **HTML Export** - Share notes as standalone HTML files - 📄 **HTML Export** - Share notes as standalone HTML files
- 🕸️ **Graph View** - Interactive visualization of connected notes - 🕸️ **Graph View** - Interactive visualization of connected notes
- ⭐ **Favorites** - Star your most-used notes for instant access - ⭐ **Favorites** - Star your most-used notes for instant access
- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC
## 🚀 Quick Start ## 🚀 Quick Start

View File

@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
def sanitize_filename(filename: str) -> str: def sanitize_filename(filename: str) -> str:
""" """
Sanitize a filename by removing/replacing invalid characters. Sanitize a filename by removing/replacing dangerous filesystem characters.
Keeps only alphanumeric chars, dots, dashes, and underscores. Supports Unicode characters (international text) while blocking:
- Windows forbidden: \\ / : * ? " < > |
- Control characters (0x00-0x1f)
Note: This is a safety net - the frontend validates before sending.
""" """
if not filename:
return filename
# Get the extension first # Get the extension first
parts = filename.rsplit('.', 1) parts = filename.rsplit('.', 1)
name = parts[0] name = parts[0]
ext = parts[1] if len(parts) > 1 else '' ext = parts[1] if len(parts) > 1 else ''
# Remove/replace invalid characters # Remove dangerous characters (replace with underscore)
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) # Blocklist approach: only remove what's truly dangerous
# Pattern: backslash, forward slash, colon, asterisk, question mark, quotes, angle brackets, pipe, control chars
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', name)
# Collapse multiple underscores
name = re.sub(r'_+', '_', name)
# Strip leading/trailing underscores and spaces
name = name.strip('_ ')
# Ensure we have something left
if not name:
name = 'unnamed'
# Rejoin with extension # Rejoin with extension
return f"{name}.{ext}" if ext else name return f"{name}.{ext}" if ext else name

View File

@ -27,7 +27,7 @@ search:
authentication: authentication:
# Authentication settings # Authentication settings
# Set enabled to true to require login # Set enabled to true to require login
enabled: true enabled: false
# ⚠️ SECURITY WARNING: Change these values before exposing to the internet! # ⚠️ SECURITY WARNING: Change these values before exposing to the internet!
# Default values below are for LOCAL TESTING ONLY # Default values below are for LOCAL TESTING ONLY

View File

@ -823,7 +823,7 @@
<div class="feature animate-on-scroll"> <div class="feature animate-on-scroll">
<div class="feature-icon">🔗</div> <div class="feature-icon">🔗</div>
<h3>Wikilinks</h3> <h3>Wikilinks</h3>
<p>Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.</p> <p>Link notes with [[double brackets]] Obsidian-style. Jump to sections with [[note#heading]] anchors.</p>
</div> </div>
<div class="feature animate-on-scroll"> <div class="feature animate-on-scroll">
@ -850,6 +850,12 @@
<p>Star your most-used notes for instant access from the sidebar.</p> <p>Star your most-used notes for instant access from the sidebar.</p>
</div> </div>
<div class="feature animate-on-scroll">
<div class="feature-icon">📑</div>
<h3>Outline Panel</h3>
<p>Navigate headings with a click-to-jump table of contents. Link to sections with anchors.</p>
</div>
<div class="feature animate-on-scroll"> <div class="feature animate-on-scroll">
<div class="feature-icon">🔌</div> <div class="feature-icon">🔌</div>
<h3>Extensible</h3> <h3>Extensible</h3>

View File

@ -37,12 +37,31 @@
### Internal Links ### Internal Links
- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking - **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking
- **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text - **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text
- **Section anchors** - `[[Note Name#heading]]` to link directly to a heading
- **Same-page anchors** - `[[#heading]]` to link within the current note
- **Broken link detection** - Non-existent note links shown dimmed - **Broken link detection** - Non-existent note links shown dimmed
- **Markdown links** - `[Note Name](note.md)` standard syntax also supported - **Markdown links** - `[text](note.md)` standard syntax also supported
- **Markdown section links** - `[text](note.md#heading)` for heading anchors
- **Drag to link** - Drag notes or images into the editor to insert links - **Drag to link** - Drag notes or images into the editor to insert links
- **Click to navigate** - Jump between notes seamlessly - **Click to navigate** - Jump between notes seamlessly
- **External links** - Open in new tabs automatically - **External links** - Open in new tabs automatically
### Outline Panel
- **Table of Contents** - View all headings (H1-H6) in sidebar
- **Click to navigate** - Jump to any heading in edit or preview mode
- **Real-time updates** - Outline updates as you type
- **Hierarchical view** - Indentation shows heading structure
- **Heading count badge** - Quick indicator of document structure
### Section Link Syntax
To link to a heading, convert the heading text to a slug: **lowercase, spaces → dashes, remove special chars**.
| Heading | Slug | Link Example |
|---------|------|--------------|
| `## Getting Started` | `getting-started` | `[[note#getting-started]]` |
| `### API Reference` | `api-reference` | `[API](note#api-reference)` |
| `## What's New?` | `whats-new` | `[[#whats-new]]` (same page) |
### Direct URLs ### Direct URLs
- **Deep linking** - Open specific notes via URL (e.g., `/folder/note`) - **Deep linking** - Open specific notes via URL (e.g., `/folder/note`)
- **Search highlighting** - Add `?search=term` to highlight specific content - **Search highlighting** - Add `?search=term` to highlight specific content

View File

@ -32,12 +32,103 @@ const ErrorHandler = {
} }
}; };
/**
* Centralized filename validation
* Supports Unicode characters (international text) but blocks dangerous filesystem characters.
* Does NOT silently modify filenames - validates and returns status.
*/
const FilenameValidator = {
// Characters that are forbidden in filenames across Windows/macOS/Linux
// Windows: \ / : * ? " < > |
// macOS: / :
// Linux: / \0
// Common set to block (including control characters)
FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/,
// For display purposes - human readable list
FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |',
/**
* Validate a filename (single segment, no path separators)
* @param {string} name - The filename to validate
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
*/
validateFilename(name) {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'empty' };
}
const trimmed = name.trim();
if (!trimmed) {
return { valid: false, error: 'empty' };
}
// Check for forbidden characters
if (this.FORBIDDEN_CHARS.test(trimmed)) {
return {
valid: false,
error: 'forbidden_chars',
forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY
};
}
// Check for reserved Windows names (case-insensitive)
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
if (reservedNames.test(trimmed)) {
return { valid: false, error: 'reserved_name' };
}
// Check for names starting/ending with dots or spaces (problematic on some systems)
if (trimmed.startsWith('.') && trimmed.length === 1) {
return { valid: false, error: 'invalid_dot' };
}
if (trimmed.endsWith('.') || trimmed.endsWith(' ')) {
return { valid: false, error: 'trailing_dot_space' };
}
return { valid: true, sanitized: trimmed };
},
/**
* Validate a path (may contain forward slashes for folder separators)
* @param {string} path - The path to validate
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
*/
validatePath(path) {
if (!path || typeof path !== 'string') {
return { valid: false, error: 'empty' };
}
const trimmed = path.trim();
if (!trimmed) {
return { valid: false, error: 'empty' };
}
// Split by forward slash and validate each segment
const segments = trimmed.split('/').filter(s => s.length > 0);
if (segments.length === 0) {
return { valid: false, error: 'empty' };
}
for (const segment of segments) {
const result = this.validateFilename(segment);
if (!result.valid) {
return result;
}
}
// Rebuild path without empty segments
return { valid: true, sanitized: segments.join('/') };
}
};
function noteApp() { function noteApp() {
return { return {
// App state // App state
appName: 'NoteDiscovery', appName: 'NoteDiscovery',
appVersion: '0.0.0', appVersion: '0.0.0',
authEnabled: false, authEnabled: false,
demoMode: false,
notes: [], notes: [],
currentNote: '', currentNote: '',
currentNoteName: '', currentNoteName: '',
@ -111,6 +202,9 @@ function noteApp() {
tagsExpanded: false, tagsExpanded: false,
tagReloadTimeout: null, // For debouncing tag reloads tagReloadTimeout: null, // For debouncing tag reloads
// Outline (TOC) state
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
// Scroll sync state // Scroll sync state
isScrolling: false, isScrolling: false,
@ -382,6 +476,7 @@ function noteApp() {
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.outline = [];
// Restore homepage folder state if it was saved // Restore homepage folder state if it was saved
if (e.state && e.state.homepageFolder !== undefined) { if (e.state && e.state.homepageFolder !== undefined) {
@ -569,6 +664,7 @@ function noteApp() {
this.appName = config.name; this.appName = config.name;
this.appVersion = config.version || '0.0.0'; this.appVersion = config.version || '0.0.0';
this.authEnabled = config.authentication?.enabled || false; this.authEnabled = config.authentication?.enabled || false;
this.demoMode = config.demoMode || false;
} catch (error) { } catch (error) {
console.error('Failed to load config:', error); console.error('Failed to load config:', error);
} }
@ -802,6 +898,35 @@ function noteApp() {
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
}, },
/**
* Get localized error message from FilenameValidator result
* @param {object} validation - The validation result from FilenameValidator
* @param {string} type - 'note' or 'folder'
* @returns {string} Localized error message
*/
getValidationErrorMessage(validation, type = 'note') {
switch (validation.error) {
case 'empty':
return type === 'note'
? this.t('notes.empty_name')
: this.t('folders.invalid_name');
case 'forbidden_chars':
return this.t('validation.forbidden_chars', {
chars: validation.forbiddenChars
});
case 'reserved_name':
return this.t('validation.reserved_name');
case 'invalid_dot':
return this.t('validation.invalid_dot');
case 'trailing_dot_space':
return this.t('validation.trailing_dot_space');
default:
return type === 'note'
? this.t('notes.invalid_name')
: this.t('folders.invalid_name');
}
},
// Load available locales from backend // Load available locales from backend
async loadAvailableLocales() { async loadAvailableLocales() {
try { try {
@ -968,8 +1093,15 @@ function noteApp() {
} }
try { try {
// Validate the note name
const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note'));
return;
}
// Determine the note path based on dropdown context // Determine the note path based on dropdown context
let notePath = this.newTemplateNoteName.trim(); let notePath = validation.sanitized;
if (!notePath.endsWith('.md')) { if (!notePath.endsWith('.md')) {
notePath += '.md'; notePath += '.md';
} }
@ -990,7 +1122,7 @@ function noteApp() {
// CRITICAL: Check if note already exists // CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath); const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() })); alert(this.t('notes.already_exists', { name: validation.sanitized }));
return; return;
} }
@ -1034,6 +1166,121 @@ function noteApp() {
this.applyFilters(); this.applyFilters();
}, },
// ========================================================================
// Outline (TOC) Methods
// ========================================================================
// Extract headings from markdown content for the outline
extractOutline(content) {
if (!content) {
this.outline = [];
return;
}
const headings = [];
const lines = content.split('\n');
const slugCounts = {}; // Track duplicate slugs
// Skip frontmatter
let inFrontmatter = false;
let frontmatterEnded = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle frontmatter
if (i === 0 && line.trim() === '---') {
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
if (line.trim() === '---') {
inFrontmatter = false;
frontmatterEnded = true;
}
continue;
}
// Match heading lines (# to ######)
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
const text = match[2].trim();
// Generate slug (GitHub-style)
let slug = text
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Spaces to dashes
.replace(/-+/g, '-'); // Multiple dashes to single
// Handle duplicate slugs
if (slugCounts[slug] !== undefined) {
slugCounts[slug]++;
slug = `${slug}-${slugCounts[slug]}`;
} else {
slugCounts[slug] = 0;
}
headings.push({
level,
text,
slug,
line: i + 1 // 1-indexed line number
});
}
}
this.outline = headings;
},
// Scroll to a heading in the editor or preview
scrollToHeading(heading) {
if (this.viewMode === 'preview' || this.viewMode === 'split') {
// In preview/split mode, scroll the preview pane
const preview = document.querySelector('.markdown-preview');
if (preview) {
// Find the heading element by text content (more reliable than ID)
const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const el of headingElements) {
if (el.textContent.trim() === heading.text) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Add a brief highlight effect
el.style.transition = 'background-color 0.3s';
el.style.backgroundColor = 'var(--accent-light)';
setTimeout(() => {
el.style.backgroundColor = '';
}, 1000);
return;
}
}
}
}
if (this.viewMode === 'edit' || this.viewMode === 'split') {
// In edit/split mode, scroll the editor to the line
const textarea = document.querySelector('.editor-textarea');
if (textarea && heading.line) {
const lines = textarea.value.split('\n');
let charPos = 0;
// Calculate character position of the heading line
for (let i = 0; i < heading.line - 1 && i < lines.length; i++) {
charPos += lines[i].length + 1; // +1 for newline
}
// Set cursor position and scroll
textarea.focus();
textarea.setSelectionRange(charPos, charPos);
// Calculate scroll position (approximate)
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3;
textarea.scrollTop = Math.max(0, scrollTop);
}
}
},
// Unified filtering logic combining tags and text search // Unified filtering logic combining tags and text search
async applyFilters() { async applyFilters() {
const hasTextSearch = this.searchQuery.trim().length > 0; const hasTextSearch = this.searchQuery.trim().length > 0;
@ -1945,42 +2192,79 @@ function noteApp() {
// Prevent default navigation for internal links // Prevent default navigation for internal links
event.preventDefault(); event.preventDefault();
// Remove any anchor from the href (e.g., "note.md#section" -> "note.md") // Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
// Also decode URL encoding (e.g., "note%203.md" -> "note 3.md") const decodedHref = decodeURIComponent(href);
const notePath = decodeURIComponent(href.split('#')[0]); const hashIndex = decodedHref.indexOf('#');
const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
// Skip if it's just an anchor link // If it's just an anchor link (#heading), scroll within current note
if (!notePath && anchor) {
this.scrollToAnchor(anchor);
return;
}
// Skip if no path
if (!notePath) return; if (!notePath) return;
// Find the note by path (try exact match first, then with .md extension) // Find the note by path (try exact match first, then with .md extension)
const note = this.notes.find(n => let targetNote = this.notes.find(n =>
n.path === notePath || n.path === notePath ||
n.path === notePath + '.md' n.path === notePath + '.md'
); );
if (note) {
this.loadNote(note.path); if (!targetNote) {
} else {
// Try to find by name (in case link uses just the note name without path) // Try to find by name (in case link uses just the note name without path)
const noteByName = this.notes.find(n => targetNote = this.notes.find(n =>
n.name === notePath || n.name === notePath ||
n.name === notePath + '.md' || n.name === notePath + '.md' ||
// Also match by filename at end of path (case-insensitive)
n.name.toLowerCase() === notePath.toLowerCase() || n.name.toLowerCase() === notePath.toLowerCase() ||
n.name.toLowerCase() === (notePath + '.md').toLowerCase() n.name.toLowerCase() === (notePath + '.md').toLowerCase()
); );
if (noteByName) { }
this.loadNote(noteByName.path);
} else { if (!targetNote) {
// Last resort: case-insensitive path matching // Last resort: case-insensitive path matching
const noteByPathCI = this.notes.find(n => targetNote = this.notes.find(n =>
n.path.toLowerCase() === notePath.toLowerCase() || n.path.toLowerCase() === notePath.toLowerCase() ||
n.path.toLowerCase() === (notePath + '.md').toLowerCase() n.path.toLowerCase() === (notePath + '.md').toLowerCase()
); );
if (noteByPathCI) { }
this.loadNote(noteByPathCI.path);
} else { if (targetNote) {
alert(this.t('notes.not_found', { path: notePath })); // Load the note, then scroll to anchor if present
this.loadNote(targetNote.path).then(() => {
if (anchor) {
// Small delay to ensure content is rendered
setTimeout(() => this.scrollToAnchor(anchor), 100);
} }
});
} else {
alert(this.t('notes.not_found', { path: notePath }));
}
},
// Scroll to an anchor (heading) by slug - reuses outline data
scrollToAnchor(anchor) {
// Normalize the anchor (GitHub-style slug)
const targetSlug = anchor
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Find matching heading in outline
const heading = this.outline.find(h => h.slug === targetSlug);
if (heading) {
this.scrollToHeading(heading);
} else {
// Fallback: try to find heading by exact text match
const headingByText = this.outline.find(h =>
h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
);
if (headingByText) {
this.scrollToHeading(headingByText);
} }
} }
}, },
@ -2182,6 +2466,9 @@ function noteApp() {
this.currentImage = ''; // Clear image viewer when loading a note this.currentImage = ''; // Clear image viewer when loading a note
this.lastSaved = false; this.lastSaved = false;
// Extract outline for TOC panel
this.extractOutline(data.content);
// Initialize undo/redo history for this note (with cursor at start) // Initialize undo/redo history for this note (with cursor at start)
this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.undoHistory = [{ content: data.content, cursorPos: 0 }];
this.redoHistory = []; this.redoHistory = [];
@ -2548,23 +2835,29 @@ function noteApp() {
const noteName = prompt(promptText); const noteName = prompt(promptText);
if (!noteName) return; if (!noteName) return;
const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); // Validate the name/path (may contain / for paths when no target folder)
if (!sanitizedName) { const validation = targetFolder
alert(this.t('notes.invalid_name')); ? FilenameValidator.validateFilename(noteName)
: FilenameValidator.validatePath(noteName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note'));
return; return;
} }
const validatedName = validation.sanitized;
let notePath; let notePath;
if (targetFolder) { if (targetFolder) {
notePath = `${targetFolder}/${sanitizedName}.md`; notePath = `${targetFolder}/${validatedName}.md`;
} else { } else {
notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
} }
// CRITICAL: Check if note already exists // CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath); const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: sanitizedName })); alert(this.t('notes.already_exists', { name: validatedName }));
return; return;
} }
@ -2609,18 +2902,23 @@ function noteApp() {
const folderName = prompt(promptText); const folderName = prompt(promptText);
if (!folderName) return; if (!folderName) return;
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); // Validate the name/path (may contain / for paths when no target folder)
if (!sanitizedName) { const validation = targetFolder
alert(this.t('folders.invalid_name')); ? FilenameValidator.validateFilename(folderName)
: FilenameValidator.validatePath(folderName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder'));
return; return;
} }
const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName; const validatedName = validation.sanitized;
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
// Check if folder already exists // Check if folder already exists
const existingFolder = this.allFolders.find(folder => folder === folderPath); const existingFolder = this.allFolders.find(folder => folder === folderPath);
if (existingFolder) { if (existingFolder) {
alert(this.t('folders.already_exists', { name: sanitizedName })); alert(this.t('folders.already_exists', { name: validatedName }));
return; return;
} }
@ -2653,15 +2951,18 @@ function noteApp() {
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName); const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
if (!newName || newName === currentName) return; if (!newName || newName === currentName) return;
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); // Validate the new name (single segment, no path separators)
if (!sanitizedName) { const validation = FilenameValidator.validateFilename(newName);
alert(this.t('folders.invalid_name')); if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder'));
return; return;
} }
const validatedName = validation.sanitized;
// Calculate new path // Calculate new path
const pathParts = folderPath.split('/'); const pathParts = folderPath.split('/');
pathParts[pathParts.length - 1] = sanitizedName; pathParts[pathParts.length - 1] = validatedName;
const newPath = pathParts.join('/'); const newPath = pathParts.join('/');
try { try {
@ -2772,6 +3073,9 @@ function noteApp() {
// Parse metadata in real-time // Parse metadata in real-time
this.parseMetadata(); this.parseMetadata();
// Update outline (TOC) in real-time
this.extractOutline(this.noteContent);
this.saveTimeout = setTimeout(() => { this.saveTimeout = setTimeout(() => {
this.saveNote(); this.saveNote();
}, CONFIG.AUTOSAVE_DELAY); }, CONFIG.AUTOSAVE_DELAY);
@ -3025,15 +3329,25 @@ function noteApp() {
return; return;
} }
// Validate the new name (single segment, no path separators)
const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note'));
// Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return;
}
const validatedName = validation.sanitized;
const folder = oldPath.split('/').slice(0, -1).join('/'); const folder = oldPath.split('/').slice(0, -1).join('/');
const newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`; const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
if (oldPath === newPath) return; if (oldPath === newPath) return;
// Check if a note with the new name already exists // Check if a note with the new name already exists
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: newName })); alert(this.t('notes.already_exists', { name: validatedName }));
// Reset the name in the UI // Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return; return;
@ -4489,6 +4803,7 @@ function noteApp() {
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentImage = '';
this.outline = [];
// Invalidate cache to force recalculation // Invalidate cache to force recalculation
this._homepageCache = { this._homepageCache = {
@ -4509,6 +4824,7 @@ function noteApp() {
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentImage = '';
this.outline = [];
this.mobileSidebarOpen = false; this.mobileSidebarOpen = false;
// Invalidate cache to force recalculation // Invalidate cache to force recalculation

View File

@ -1157,7 +1157,7 @@
:class="{'active': activePanel === 'files'}" :class="{'active': activePanel === 'files'}"
@click="activePanel = 'files'" @click="activePanel = 'files'"
:title="t('sidebar.files')" :title="t('sidebar.files')"
aria-label="Files panel" :aria-label="t('sidebar.files')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
@ -1170,7 +1170,7 @@
:class="{'active': activePanel === 'search'}" :class="{'active': activePanel === 'search'}"
@click="activePanel = 'search'" @click="activePanel = 'search'"
:title="t('sidebar.search')" :title="t('sidebar.search')"
aria-label="Search panel" :aria-label="t('sidebar.search')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
@ -1191,7 +1191,7 @@
:class="{'active': activePanel === 'tags'}" :class="{'active': activePanel === 'tags'}"
@click="activePanel = 'tags'" @click="activePanel = 'tags'"
:title="t('tags.title')" :title="t('tags.title')"
aria-label="Tags panel" :aria-label="t('tags.title')"
> >
<svg fill="currentColor" viewBox="0 0 20 20"> <svg fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
@ -1206,6 +1206,27 @@
></span> ></span>
</button> </button>
<!-- Outline (TOC) -->
<button
class="icon-rail-btn relative"
:class="{'active': activePanel === 'outline'}"
@click="activePanel = 'outline'"
:title="t('outline.title')"
:aria-label="t('outline.title')"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<!-- Heading count badge -->
<span
x-show="outline.length > 0"
x-cloak
class="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-bold rounded-full flex items-center justify-center"
style="background-color: var(--accent-primary); color: white;"
x-text="outline.length > 9 ? '9+' : outline.length"
></span>
</button>
<div class="icon-rail-spacer"></div> <div class="icon-rail-spacer"></div>
<!-- Graph --> <!-- Graph -->
@ -1214,7 +1235,7 @@
:class="{'active': showGraph}" :class="{'active': showGraph}"
@click="showGraph = true; initGraph()" @click="showGraph = true; initGraph()"
:title="t('graph.title')" :title="t('graph.title')"
aria-label="Graph view" :aria-label="t('graph.title')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
@ -1227,7 +1248,7 @@
:class="{'active': activePanel === 'settings'}" :class="{'active': activePanel === 'settings'}"
@click="activePanel = 'settings'" @click="activePanel = 'settings'"
:title="t('sidebar.settings')" :title="t('sidebar.settings')"
aria-label="Settings panel" :aria-label="t('sidebar.settings')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
@ -1578,6 +1599,51 @@
</div> </div>
<!-- END TAGS PANEL --> <!-- END TAGS PANEL -->
<!-- ==================== OUTLINE PANEL ==================== -->
<div x-show="activePanel === 'outline'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
<!-- Outline Header -->
<div class="flex-shrink-0 px-3 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('outline.title')"></span>
<span class="text-xs px-1.5 py-0.5 rounded" style="background-color: var(--bg-tertiary); color: var(--text-tertiary);" x-text="outline.length"></span>
</div>
<!-- Scrollable content: Outline items -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Outline items -->
<div class="py-2">
<template x-if="outline.length > 0">
<div>
<template x-for="(heading, index) in outline" :key="index">
<button
@click="scrollToHeading(heading)"
class="w-full text-left px-3 py-1.5 text-sm transition-colors hover:brightness-110"
:style="`padding-left: ${(heading.level - 1) * 12 + 12}px; color: var(--text-primary);`"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'"
>
<span
class="block truncate"
:style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')"
x-text="heading.text"
></span>
</button>
</template>
</div>
</template>
<template x-if="outline.length === 0">
<div class="text-center py-6 px-3">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<p class="text-sm mb-2" style="color: var(--text-tertiary);" x-text="t('outline.no_headings')"></p>
<p class="text-xs" style="color: var(--text-tertiary);" x-text="t('outline.hint')"></p>
</div>
</template>
</div>
</div>
</div>
<!-- END OUTLINE PANEL -->
<!-- ==================== SETTINGS PANEL ==================== --> <!-- ==================== SETTINGS PANEL ==================== -->
<template x-if="activePanel === 'settings'"> <template x-if="activePanel === 'settings'">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
@ -1668,6 +1734,21 @@
> >
notediscovery.com notediscovery.com
</a> </a>
<!-- Ko-fi support link (demo mode only) -->
<template x-if="demoMode">
<div class="mt-3">
<a
href="https://ko-fi.com/gamosoft"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full transition-all hover:scale-105"
style="background-color: #ff5e5b; color: white;"
>
<span></span>
<span x-text="t('support.enjoying_demo')"></span>
</a>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -2590,23 +2671,23 @@
class="mobile-bottom-tab" class="mobile-bottom-tab"
:class="{'active': activePanel === 'files'}" :class="{'active': activePanel === 'files'}"
@click="activePanel = 'files'; mobileSidebarOpen = true;" @click="activePanel = 'files'; mobileSidebarOpen = true;"
aria-label="Files" :aria-label="t('sidebar.files')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg> </svg>
<span>Files</span> <span x-text="t('sidebar.files')"></span>
</button> </button>
<button <button
class="mobile-bottom-tab relative" class="mobile-bottom-tab relative"
:class="{'active': activePanel === 'search'}" :class="{'active': activePanel === 'search'}"
@click="activePanel = 'search'; mobileSidebarOpen = true;" @click="activePanel = 'search'; mobileSidebarOpen = true;"
aria-label="Search" :aria-label="t('sidebar.search')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
<span>Search</span> <span x-text="t('sidebar.search')"></span>
<!-- Search results badge --> <!-- Search results badge -->
<span <span
x-show="searchQuery.trim() && searchResults.length > 0" x-show="searchQuery.trim() && searchResults.length > 0"
@ -2620,12 +2701,12 @@
class="mobile-bottom-tab relative" class="mobile-bottom-tab relative"
:class="{'active': activePanel === 'tags'}" :class="{'active': activePanel === 'tags'}"
@click="activePanel = 'tags'; mobileSidebarOpen = true;" @click="activePanel = 'tags'; mobileSidebarOpen = true;"
aria-label="Tags" :aria-label="t('tags.title')"
> >
<svg fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> <svg fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg> </svg>
<span>Tags</span> <span x-text="t('tags.title')"></span>
<!-- Filtered notes badge (shows result count when tags selected) --> <!-- Filtered notes badge (shows result count when tags selected) -->
<span <span
x-show="selectedTags.length > 0" x-show="selectedTags.length > 0"
@ -2639,24 +2720,24 @@
class="mobile-bottom-tab" class="mobile-bottom-tab"
:class="{'active': showGraph}" :class="{'active': showGraph}"
@click="showGraph = true; initGraph(); mobileSidebarOpen = false;" @click="showGraph = true; initGraph(); mobileSidebarOpen = false;"
aria-label="Graph" :aria-label="t('graph.title')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg> </svg>
<span>Graph</span> <span x-text="t('graph.title')"></span>
</button> </button>
<button <button
class="mobile-bottom-tab" class="mobile-bottom-tab"
:class="{'active': activePanel === 'settings'}" :class="{'active': activePanel === 'settings'}"
@click="activePanel = 'settings'; mobileSidebarOpen = true;" @click="activePanel = 'settings'; mobileSidebarOpen = true;"
aria-label="Settings" :aria-label="t('sidebar.settings')"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg> </svg>
<span>Settings</span> <span x-text="t('sidebar.settings')"></span>
</button> </button>
</nav> </nav>

View File

@ -121,6 +121,12 @@
"filter_by": "Nach {{tag}} filtern ({{count}} Notizen)" "filter_by": "Nach {{tag}} filtern ({{count}} Notizen)"
}, },
"outline": {
"title": "Gliederung",
"no_headings": "Keine Überschriften gefunden",
"hint": "Überschriften mit # hinzufügen"
},
"stats": { "stats": {
"words": "Wörter", "words": "Wörter",
"reading_time": "~{{minutes}}m Lesezeit", "reading_time": "~{{minutes}}m Lesezeit",
@ -211,6 +217,17 @@
"note_plural": "Notizen", "note_plural": "Notizen",
"folder_singular": "Ordner", "folder_singular": "Ordner",
"folder_plural": "Ordner" "folder_plural": "Ordner"
},
"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"
} }
} }

View File

@ -121,6 +121,12 @@
"filter_by": "Filter by {{tag}} ({{count}} notes)" "filter_by": "Filter by {{tag}} ({{count}} notes)"
}, },
"outline": {
"title": "Outline",
"no_headings": "No headings found",
"hint": "Add headings using # syntax"
},
"stats": { "stats": {
"words": "words", "words": "words",
"reading_time": "~{{minutes}}m read", "reading_time": "~{{minutes}}m read",
@ -211,6 +217,17 @@
"note_plural": "notes", "note_plural": "notes",
"folder_singular": "folder", "folder_singular": "folder",
"folder_plural": "folders" "folder_plural": "folders"
},
"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"
} }
} }

View File

@ -121,6 +121,12 @@
"filter_by": "Filtrar por {{tag}} ({{count}} notas)" "filter_by": "Filtrar por {{tag}} ({{count}} notas)"
}, },
"outline": {
"title": "Esquema",
"no_headings": "No se encontraron encabezados",
"hint": "Añade encabezados usando sintaxis #"
},
"stats": { "stats": {
"words": "palabras", "words": "palabras",
"reading_time": "~{{minutes}}m de lectura", "reading_time": "~{{minutes}}m de lectura",
@ -211,6 +217,17 @@
"note_plural": "notas", "note_plural": "notas",
"folder_singular": "carpeta", "folder_singular": "carpeta",
"folder_plural": "carpetas" "folder_plural": "carpetas"
},
"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"
} }
} }

View File

@ -121,6 +121,12 @@
"filter_by": "Filtrer par {{tag}} ({{count}} notes)" "filter_by": "Filtrer par {{tag}} ({{count}} notes)"
}, },
"outline": {
"title": "Plan",
"no_headings": "Aucun titre trouvé",
"hint": "Ajoutez des titres avec la syntaxe #"
},
"stats": { "stats": {
"words": "mots", "words": "mots",
"reading_time": "~{{minutes}}m de lecture", "reading_time": "~{{minutes}}m de lecture",
@ -211,6 +217,17 @@
"note_plural": "notes", "note_plural": "notes",
"folder_singular": "dossier", "folder_singular": "dossier",
"folder_plural": "dossiers" "folder_plural": "dossiers"
},
"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"
} }
} }

View File

@ -121,6 +121,12 @@
"filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)" "filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)"
}, },
"outline": {
"title": "大纲",
"no_headings": "未找到标题",
"hint": "使用 # 语法添加标题"
},
"stats": { "stats": {
"words": "字数", "words": "字数",
"reading_time": "~{{minutes}}分钟阅读", "reading_time": "~{{minutes}}分钟阅读",
@ -211,6 +217,17 @@
"note_plural": "笔记", "note_plural": "笔记",
"folder_singular": "文件夹", "folder_singular": "文件夹",
"folder_plural": "文件夹" "folder_plural": "文件夹"
},
"validation": {
"forbidden_chars": "名称包含禁止的字符:{{chars}}",
"reserved_name": "此名称被操作系统保留。",
"invalid_dot": "名称不能只是一个点。",
"trailing_dot_space": "名称不能以点或空格结尾。"
},
"support": {
"enjoying_demo": "支持此项目"
} }
} }