Merge pull request #110 from gamosoft/features/table-of-contents
Features/table of contents
This commit is contained in:
commit
1a05bf3968
|
|
@ -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
|
||||
- 🕸️ **Graph View** - Interactive visualization of connected notes
|
||||
- ⭐ **Favorites** - Star your most-used notes for instant access
|
||||
- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
|
|||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize a filename by removing/replacing invalid characters.
|
||||
Keeps only alphanumeric chars, dots, dashes, and underscores.
|
||||
Sanitize a filename by removing/replacing dangerous filesystem characters.
|
||||
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
|
||||
parts = filename.rsplit('.', 1)
|
||||
name = parts[0]
|
||||
ext = parts[1] if len(parts) > 1 else ''
|
||||
|
||||
# Remove/replace invalid characters
|
||||
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||
# Remove dangerous characters (replace with underscore)
|
||||
# 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
|
||||
return f"{name}.{ext}" if ext else name
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ search:
|
|||
authentication:
|
||||
# Authentication settings
|
||||
# Set enabled to true to require login
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
||||
# ⚠️ SECURITY WARNING: Change these values before exposing to the internet!
|
||||
# Default values below are for LOCAL TESTING ONLY
|
||||
|
|
|
|||
|
|
@ -823,7 +823,7 @@
|
|||
<div class="feature animate-on-scroll">
|
||||
<div class="feature-icon">🔗</div>
|
||||
<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 class="feature animate-on-scroll">
|
||||
|
|
@ -850,6 +850,12 @@
|
|||
<p>Star your most-used notes for instant access from the sidebar.</p>
|
||||
</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-icon">🔌</div>
|
||||
<h3>Extensible</h3>
|
||||
|
|
|
|||
|
|
@ -37,12 +37,31 @@
|
|||
### Internal Links
|
||||
- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking
|
||||
- **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
|
||||
- **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
|
||||
- **Click to navigate** - Jump between notes seamlessly
|
||||
- **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
|
||||
- **Deep linking** - Open specific notes via URL (e.g., `/folder/note`)
|
||||
- **Search highlighting** - Add `?search=term` to highlight specific content
|
||||
|
|
|
|||
398
frontend/app.js
398
frontend/app.js
|
|
@ -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() {
|
||||
return {
|
||||
// App state
|
||||
appName: 'NoteDiscovery',
|
||||
appVersion: '0.0.0',
|
||||
authEnabled: false,
|
||||
demoMode: false,
|
||||
notes: [],
|
||||
currentNote: '',
|
||||
currentNoteName: '',
|
||||
|
|
@ -111,6 +202,9 @@ function noteApp() {
|
|||
tagsExpanded: false,
|
||||
tagReloadTimeout: null, // For debouncing tag reloads
|
||||
|
||||
// Outline (TOC) state
|
||||
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
|
||||
|
||||
// Scroll sync state
|
||||
isScrolling: false,
|
||||
|
||||
|
|
@ -382,6 +476,7 @@ function noteApp() {
|
|||
this.currentNote = '';
|
||||
this.noteContent = '';
|
||||
this.currentNoteName = '';
|
||||
this.outline = [];
|
||||
|
||||
// Restore homepage folder state if it was saved
|
||||
if (e.state && e.state.homepageFolder !== undefined) {
|
||||
|
|
@ -569,6 +664,7 @@ function noteApp() {
|
|||
this.appName = config.name;
|
||||
this.appVersion = config.version || '0.0.0';
|
||||
this.authEnabled = config.authentication?.enabled || false;
|
||||
this.demoMode = config.demoMode || false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
|
|
@ -802,6 +898,35 @@ function noteApp() {
|
|||
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
|
||||
async loadAvailableLocales() {
|
||||
try {
|
||||
|
|
@ -968,8 +1093,15 @@ function noteApp() {
|
|||
}
|
||||
|
||||
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
|
||||
let notePath = this.newTemplateNoteName.trim();
|
||||
let notePath = validation.sanitized;
|
||||
if (!notePath.endsWith('.md')) {
|
||||
notePath += '.md';
|
||||
}
|
||||
|
|
@ -990,7 +1122,7 @@ function noteApp() {
|
|||
// CRITICAL: Check if note already exists
|
||||
const existingNote = this.notes.find(note => note.path === notePath);
|
||||
if (existingNote) {
|
||||
alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() }));
|
||||
alert(this.t('notes.already_exists', { name: validation.sanitized }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1034,6 +1166,121 @@ function noteApp() {
|
|||
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
|
||||
async applyFilters() {
|
||||
const hasTextSearch = this.searchQuery.trim().length > 0;
|
||||
|
|
@ -1945,42 +2192,79 @@ function noteApp() {
|
|||
// Prevent default navigation for internal links
|
||||
event.preventDefault();
|
||||
|
||||
// Remove any anchor from the href (e.g., "note.md#section" -> "note.md")
|
||||
// Also decode URL encoding (e.g., "note%203.md" -> "note 3.md")
|
||||
const notePath = decodeURIComponent(href.split('#')[0]);
|
||||
// Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
|
||||
const decodedHref = decodeURIComponent(href);
|
||||
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;
|
||||
|
||||
// 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 + '.md'
|
||||
);
|
||||
if (note) {
|
||||
this.loadNote(note.path);
|
||||
} else {
|
||||
|
||||
if (!targetNote) {
|
||||
// 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 + '.md' ||
|
||||
// Also match by filename at end of path (case-insensitive)
|
||||
n.name.toLowerCase() === notePath.toLowerCase() ||
|
||||
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||
);
|
||||
if (noteByName) {
|
||||
this.loadNote(noteByName.path);
|
||||
} else {
|
||||
// Last resort: case-insensitive path matching
|
||||
const noteByPathCI = this.notes.find(n =>
|
||||
n.path.toLowerCase() === notePath.toLowerCase() ||
|
||||
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||
);
|
||||
if (noteByPathCI) {
|
||||
this.loadNote(noteByPathCI.path);
|
||||
} else {
|
||||
alert(this.t('notes.not_found', { path: notePath }));
|
||||
}
|
||||
|
||||
if (!targetNote) {
|
||||
// Last resort: case-insensitive path matching
|
||||
targetNote = this.notes.find(n =>
|
||||
n.path.toLowerCase() === notePath.toLowerCase() ||
|
||||
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (targetNote) {
|
||||
// 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.lastSaved = false;
|
||||
|
||||
// Extract outline for TOC panel
|
||||
this.extractOutline(data.content);
|
||||
|
||||
// Initialize undo/redo history for this note (with cursor at start)
|
||||
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
|
||||
this.redoHistory = [];
|
||||
|
|
@ -2548,23 +2835,29 @@ function noteApp() {
|
|||
const noteName = prompt(promptText);
|
||||
if (!noteName) return;
|
||||
|
||||
const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, '');
|
||||
if (!sanitizedName) {
|
||||
alert(this.t('notes.invalid_name'));
|
||||
// Validate the name/path (may contain / for paths when no target folder)
|
||||
const validation = targetFolder
|
||||
? FilenameValidator.validateFilename(noteName)
|
||||
: FilenameValidator.validatePath(noteName);
|
||||
|
||||
if (!validation.valid) {
|
||||
alert(this.getValidationErrorMessage(validation, 'note'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedName = validation.sanitized;
|
||||
|
||||
let notePath;
|
||||
if (targetFolder) {
|
||||
notePath = `${targetFolder}/${sanitizedName}.md`;
|
||||
notePath = `${targetFolder}/${validatedName}.md`;
|
||||
} else {
|
||||
notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`;
|
||||
notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
|
||||
}
|
||||
|
||||
// CRITICAL: Check if note already exists
|
||||
const existingNote = this.notes.find(note => note.path === notePath);
|
||||
if (existingNote) {
|
||||
alert(this.t('notes.already_exists', { name: sanitizedName }));
|
||||
alert(this.t('notes.already_exists', { name: validatedName }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2609,18 +2902,23 @@ function noteApp() {
|
|||
const folderName = prompt(promptText);
|
||||
if (!folderName) return;
|
||||
|
||||
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, '');
|
||||
if (!sanitizedName) {
|
||||
alert(this.t('folders.invalid_name'));
|
||||
// Validate the name/path (may contain / for paths when no target folder)
|
||||
const validation = targetFolder
|
||||
? FilenameValidator.validateFilename(folderName)
|
||||
: FilenameValidator.validatePath(folderName);
|
||||
|
||||
if (!validation.valid) {
|
||||
alert(this.getValidationErrorMessage(validation, 'folder'));
|
||||
return;
|
||||
}
|
||||
|
||||
const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName;
|
||||
const validatedName = validation.sanitized;
|
||||
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
|
||||
|
||||
// Check if folder already exists
|
||||
const existingFolder = this.allFolders.find(folder => folder === folderPath);
|
||||
if (existingFolder) {
|
||||
alert(this.t('folders.already_exists', { name: sanitizedName }));
|
||||
alert(this.t('folders.already_exists', { name: validatedName }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2653,15 +2951,18 @@ function noteApp() {
|
|||
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
|
||||
if (!newName || newName === currentName) return;
|
||||
|
||||
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, '');
|
||||
if (!sanitizedName) {
|
||||
alert(this.t('folders.invalid_name'));
|
||||
// Validate the new name (single segment, no path separators)
|
||||
const validation = FilenameValidator.validateFilename(newName);
|
||||
if (!validation.valid) {
|
||||
alert(this.getValidationErrorMessage(validation, 'folder'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedName = validation.sanitized;
|
||||
|
||||
// Calculate new path
|
||||
const pathParts = folderPath.split('/');
|
||||
pathParts[pathParts.length - 1] = sanitizedName;
|
||||
pathParts[pathParts.length - 1] = validatedName;
|
||||
const newPath = pathParts.join('/');
|
||||
|
||||
try {
|
||||
|
|
@ -2772,6 +3073,9 @@ function noteApp() {
|
|||
// Parse metadata in real-time
|
||||
this.parseMetadata();
|
||||
|
||||
// Update outline (TOC) in real-time
|
||||
this.extractOutline(this.noteContent);
|
||||
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.saveNote();
|
||||
}, CONFIG.AUTOSAVE_DELAY);
|
||||
|
|
@ -3025,15 +3329,25 @@ function noteApp() {
|
|||
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 newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`;
|
||||
const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
|
||||
|
||||
if (oldPath === newPath) return;
|
||||
|
||||
// Check if a note with the new name already exists
|
||||
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingNote) {
|
||||
alert(this.t('notes.already_exists', { name: newName }));
|
||||
alert(this.t('notes.already_exists', { name: validatedName }));
|
||||
// Reset the name in the UI
|
||||
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
||||
return;
|
||||
|
|
@ -4489,6 +4803,7 @@ function noteApp() {
|
|||
this.currentNoteName = '';
|
||||
this.noteContent = '';
|
||||
this.currentImage = '';
|
||||
this.outline = [];
|
||||
|
||||
// Invalidate cache to force recalculation
|
||||
this._homepageCache = {
|
||||
|
|
@ -4509,6 +4824,7 @@ function noteApp() {
|
|||
this.currentNoteName = '';
|
||||
this.noteContent = '';
|
||||
this.currentImage = '';
|
||||
this.outline = [];
|
||||
this.mobileSidebarOpen = false;
|
||||
|
||||
// Invalidate cache to force recalculation
|
||||
|
|
|
|||
|
|
@ -1157,7 +1157,7 @@
|
|||
:class="{'active': activePanel === 'files'}"
|
||||
@click="activePanel = 'files'"
|
||||
:title="t('sidebar.files')"
|
||||
aria-label="Files panel"
|
||||
:aria-label="t('sidebar.files')"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1170,7 +1170,7 @@
|
|||
:class="{'active': activePanel === 'search'}"
|
||||
@click="activePanel = 'search'"
|
||||
:title="t('sidebar.search')"
|
||||
aria-label="Search panel"
|
||||
:aria-label="t('sidebar.search')"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1191,7 +1191,7 @@
|
|||
:class="{'active': activePanel === 'tags'}"
|
||||
@click="activePanel = 'tags'"
|
||||
:title="t('tags.title')"
|
||||
aria-label="Tags panel"
|
||||
:aria-label="t('tags.title')"
|
||||
>
|
||||
<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"/>
|
||||
|
|
@ -1206,6 +1206,27 @@
|
|||
></span>
|
||||
</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>
|
||||
|
||||
<!-- Graph -->
|
||||
|
|
@ -1214,7 +1235,7 @@
|
|||
:class="{'active': showGraph}"
|
||||
@click="showGraph = true; initGraph()"
|
||||
:title="t('graph.title')"
|
||||
aria-label="Graph view"
|
||||
:aria-label="t('graph.title')"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1227,7 +1248,7 @@
|
|||
:class="{'active': activePanel === 'settings'}"
|
||||
@click="activePanel = 'settings'"
|
||||
:title="t('sidebar.settings')"
|
||||
aria-label="Settings panel"
|
||||
:aria-label="t('sidebar.settings')"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1578,6 +1599,51 @@
|
|||
</div>
|
||||
<!-- 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 ==================== -->
|
||||
<template x-if="activePanel === 'settings'">
|
||||
<div class="flex flex-col h-full">
|
||||
|
|
@ -1667,7 +1733,22 @@
|
|||
style="color: var(--accent-primary);"
|
||||
>
|
||||
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>
|
||||
|
|
@ -2590,23 +2671,23 @@
|
|||
class="mobile-bottom-tab"
|
||||
:class="{'active': activePanel === 'files'}"
|
||||
@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">
|
||||
<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>
|
||||
<span>Files</span>
|
||||
<span x-text="t('sidebar.files')"></span>
|
||||
</button>
|
||||
<button
|
||||
class="mobile-bottom-tab relative"
|
||||
:class="{'active': activePanel === 'search'}"
|
||||
@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">
|
||||
<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>
|
||||
<span>Search</span>
|
||||
<span x-text="t('sidebar.search')"></span>
|
||||
<!-- Search results badge -->
|
||||
<span
|
||||
x-show="searchQuery.trim() && searchResults.length > 0"
|
||||
|
|
@ -2620,12 +2701,12 @@
|
|||
class="mobile-bottom-tab relative"
|
||||
:class="{'active': activePanel === 'tags'}"
|
||||
@click="activePanel = 'tags'; mobileSidebarOpen = true;"
|
||||
aria-label="Tags"
|
||||
:aria-label="t('tags.title')"
|
||||
>
|
||||
<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"/>
|
||||
</svg>
|
||||
<span>Tags</span>
|
||||
<span x-text="t('tags.title')"></span>
|
||||
<!-- Filtered notes badge (shows result count when tags selected) -->
|
||||
<span
|
||||
x-show="selectedTags.length > 0"
|
||||
|
|
@ -2639,24 +2720,24 @@
|
|||
class="mobile-bottom-tab"
|
||||
:class="{'active': showGraph}"
|
||||
@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">
|
||||
<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>
|
||||
<span>Graph</span>
|
||||
<span x-text="t('graph.title')"></span>
|
||||
</button>
|
||||
<button
|
||||
class="mobile-bottom-tab"
|
||||
:class="{'active': activePanel === 'settings'}"
|
||||
@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">
|
||||
<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>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
<span x-text="t('sidebar.settings')"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
"filter_by": "Nach {{tag}} filtern ({{count}} Notizen)"
|
||||
},
|
||||
|
||||
"outline": {
|
||||
"title": "Gliederung",
|
||||
"no_headings": "Keine Überschriften gefunden",
|
||||
"hint": "Überschriften mit # hinzufügen"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "Wörter",
|
||||
"reading_time": "~{{minutes}}m Lesezeit",
|
||||
|
|
@ -211,6 +217,17 @@
|
|||
"note_plural": "Notizen",
|
||||
"folder_singular": "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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
"filter_by": "Filter by {{tag}} ({{count}} notes)"
|
||||
},
|
||||
|
||||
"outline": {
|
||||
"title": "Outline",
|
||||
"no_headings": "No headings found",
|
||||
"hint": "Add headings using # syntax"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "words",
|
||||
"reading_time": "~{{minutes}}m read",
|
||||
|
|
@ -211,6 +217,17 @@
|
|||
"note_plural": "notes",
|
||||
"folder_singular": "folder",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
"filter_by": "Filtrar por {{tag}} ({{count}} notas)"
|
||||
},
|
||||
|
||||
"outline": {
|
||||
"title": "Esquema",
|
||||
"no_headings": "No se encontraron encabezados",
|
||||
"hint": "Añade encabezados usando sintaxis #"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "palabras",
|
||||
"reading_time": "~{{minutes}}m de lectura",
|
||||
|
|
@ -211,6 +217,17 @@
|
|||
"note_plural": "notas",
|
||||
"folder_singular": "carpeta",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
"filter_by": "Filtrer par {{tag}} ({{count}} notes)"
|
||||
},
|
||||
|
||||
"outline": {
|
||||
"title": "Plan",
|
||||
"no_headings": "Aucun titre trouvé",
|
||||
"hint": "Ajoutez des titres avec la syntaxe #"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "mots",
|
||||
"reading_time": "~{{minutes}}m de lecture",
|
||||
|
|
@ -211,6 +217,17 @@
|
|||
"note_plural": "notes",
|
||||
"folder_singular": "dossier",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
"filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)"
|
||||
},
|
||||
|
||||
"outline": {
|
||||
"title": "大纲",
|
||||
"no_headings": "未找到标题",
|
||||
"hint": "使用 # 语法添加标题"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "字数",
|
||||
"reading_time": "~{{minutes}}分钟阅读",
|
||||
|
|
@ -211,6 +217,17 @@
|
|||
"note_plural": "笔记",
|
||||
"folder_singular": "文件夹",
|
||||
"folder_plural": "文件夹"
|
||||
},
|
||||
|
||||
"validation": {
|
||||
"forbidden_chars": "名称包含禁止的字符:{{chars}}",
|
||||
"reserved_name": "此名称被操作系统保留。",
|
||||
"invalid_dot": "名称不能只是一个点。",
|
||||
"trailing_dot_space": "名称不能以点或空格结尾。"
|
||||
},
|
||||
|
||||
"support": {
|
||||
"enjoying_demo": "支持此项目"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue