added templating system
This commit is contained in:
parent
70c90307b7
commit
f5567a71b3
|
|
@ -38,6 +38,9 @@ from .utils import (
|
|||
validate_path_security,
|
||||
get_all_tags,
|
||||
get_notes_by_tag,
|
||||
get_templates,
|
||||
get_template_content,
|
||||
apply_template_placeholders,
|
||||
)
|
||||
from .plugins import PluginManager
|
||||
from .themes import get_available_themes, get_theme_css
|
||||
|
|
@ -777,6 +780,99 @@ async def get_notes_by_tag_endpoint(tag_name: str):
|
|||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get notes by tag"))
|
||||
|
||||
|
||||
# --- Template Endpoints ---
|
||||
|
||||
@api_router.get("/templates")
|
||||
async def list_templates():
|
||||
"""
|
||||
List all available templates from _templates folder.
|
||||
|
||||
Returns:
|
||||
List of template metadata
|
||||
"""
|
||||
try:
|
||||
templates = get_templates(config['storage']['notes_dir'])
|
||||
return {"templates": templates}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates"))
|
||||
|
||||
|
||||
@api_router.get("/templates/{template_name}")
|
||||
async def get_template(template_name: str):
|
||||
"""
|
||||
Get content of a specific template.
|
||||
|
||||
Args:
|
||||
template_name: Name of the template (without .md extension)
|
||||
|
||||
Returns:
|
||||
Template name and content
|
||||
"""
|
||||
try:
|
||||
content = get_template_content(config['storage']['notes_dir'], template_name)
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
return {
|
||||
"name": template_name,
|
||||
"content": content
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))
|
||||
|
||||
|
||||
@api_router.post("/templates/create-note")
|
||||
@limiter.limit("60/minute")
|
||||
async def create_note_from_template(request: Request, data: dict):
|
||||
"""
|
||||
Create a new note from a template with placeholder replacement.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing templateName and notePath
|
||||
|
||||
Returns:
|
||||
Success status, path, and created content
|
||||
"""
|
||||
try:
|
||||
template_name = data.get('templateName', '')
|
||||
note_path = data.get('notePath', '')
|
||||
|
||||
if not template_name or not note_path:
|
||||
raise HTTPException(status_code=400, detail="Template name and note path required")
|
||||
|
||||
# Get template content
|
||||
template_content = get_template_content(config['storage']['notes_dir'], template_name)
|
||||
|
||||
if template_content is None:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
# Apply placeholder replacements
|
||||
final_content = apply_template_placeholders(template_content, note_path)
|
||||
|
||||
# Save the note
|
||||
success = save_note(config['storage']['notes_dir'], note_path, final_content)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to create note from template")
|
||||
|
||||
# Run plugin hooks
|
||||
plugin_manager.run_hook('on_note_create', note_path=note_path, initial_content=final_content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": note_path,
|
||||
"message": "Note created from template successfully",
|
||||
"content": final_content
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create note from template"))
|
||||
|
||||
|
||||
# --- Notes Endpoints ---
|
||||
|
||||
@api_router.get("/notes")
|
||||
|
|
|
|||
105
backend/utils.py
105
backend/utils.py
|
|
@ -647,3 +647,108 @@ def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
|
|||
|
||||
return matching_notes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Template Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_templates(notes_dir: str) -> List[Dict]:
|
||||
"""
|
||||
Get all templates from the _templates folder.
|
||||
|
||||
Args:
|
||||
notes_dir: Base notes directory
|
||||
|
||||
Returns:
|
||||
List of template metadata (name, path, modified)
|
||||
"""
|
||||
templates = []
|
||||
templates_path = Path(notes_dir) / "_templates"
|
||||
|
||||
if not templates_path.exists():
|
||||
return templates
|
||||
|
||||
try:
|
||||
for template_file in templates_path.glob("*.md"):
|
||||
try:
|
||||
stat = template_file.stat()
|
||||
templates.append({
|
||||
"name": template_file.stem,
|
||||
"path": str(template_file.relative_to(notes_dir).as_posix()),
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error reading template {template_file}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error accessing templates directory: {e}")
|
||||
|
||||
return sorted(templates, key=lambda x: x['name'])
|
||||
|
||||
|
||||
def get_template_content(notes_dir: str, template_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get the content of a specific template.
|
||||
|
||||
Args:
|
||||
notes_dir: Base notes directory
|
||||
template_name: Name of the template (without .md extension)
|
||||
|
||||
Returns:
|
||||
Template content or None if not found
|
||||
"""
|
||||
template_path = Path(notes_dir) / "_templates" / f"{template_name}.md"
|
||||
|
||||
if not template_path.exists():
|
||||
return None
|
||||
|
||||
# Security check: ensure template is within notes directory
|
||||
if not validate_path_security(notes_dir, template_path):
|
||||
print(f"Security: Template path is outside notes directory: {template_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"Error reading template {template_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def apply_template_placeholders(content: str, note_path: str) -> str:
|
||||
"""
|
||||
Replace template placeholders with actual values.
|
||||
|
||||
Supported placeholders:
|
||||
{{date}} - Current date (YYYY-MM-DD)
|
||||
{{time}} - Current time (HH:MM:SS)
|
||||
{{datetime}} - Current datetime (YYYY-MM-DD HH:MM:SS)
|
||||
{{timestamp}} - Unix timestamp
|
||||
{{title}} - Note name without extension
|
||||
{{folder}} - Parent folder name
|
||||
|
||||
Args:
|
||||
content: Template content with placeholders
|
||||
note_path: Path of the note being created
|
||||
|
||||
Returns:
|
||||
Content with placeholders replaced
|
||||
"""
|
||||
now = datetime.now()
|
||||
note = Path(note_path)
|
||||
|
||||
replacements = {
|
||||
'{{date}}': now.strftime('%Y-%m-%d'),
|
||||
'{{time}}': now.strftime('%H:%M:%S'),
|
||||
'{{datetime}}': now.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'{{timestamp}}': str(int(now.timestamp())),
|
||||
'{{title}}': note.stem,
|
||||
'{{folder}}': note.parent.name if str(note.parent) != '.' else 'Root',
|
||||
}
|
||||
|
||||
result = content
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
- **Three view modes**: Edit, Split, Preview
|
||||
- **Auto-save** - Never lose your work
|
||||
- **Undo/Redo** - Ctrl+Z / Ctrl+Y support
|
||||
- **Note templates** - Create notes from templates with dynamic placeholders
|
||||
- **Syntax highlighting** for code blocks (50+ languages)
|
||||
- **Copy code blocks** - One-click copy button on hover
|
||||
- **LaTeX/Math rendering** - Beautiful mathematical equations with MathJax (see [MATHJAX.md](MATHJAX.md))
|
||||
|
|
@ -162,6 +163,49 @@ graph TD
|
|||
|
||||
📄 **See the [MERMAID](MERMAID.md) note for diagram examples and syntax reference.**
|
||||
|
||||
## 📄 Note Templates
|
||||
|
||||
Create notes from reusable templates with dynamic placeholder replacement.
|
||||
|
||||
### Creating Templates
|
||||
1. Create markdown files in `data/_templates/` folder
|
||||
2. Use placeholders for dynamic content
|
||||
3. Templates appear in the "New from Template" menu
|
||||
|
||||
### Available Placeholders
|
||||
- `{{date}}` - Current date (YYYY-MM-DD)
|
||||
- `{{time}}` - Current time (HH:MM:SS)
|
||||
- `{{datetime}}` - Current date and time
|
||||
- `{{timestamp}}` - Unix timestamp
|
||||
- `{{title}}` - Note name (without extension)
|
||||
- `{{folder}}` - Parent folder name
|
||||
|
||||
### Example Template
|
||||
```markdown
|
||||
---
|
||||
tags: [meeting]
|
||||
date: {{date}}
|
||||
---
|
||||
|
||||
# {{title}}
|
||||
|
||||
**Created:** {{datetime}}
|
||||
|
||||
## Notes
|
||||
|
||||
```
|
||||
|
||||
### Using Templates
|
||||
1. Click the "New" dropdown button
|
||||
2. Select "New from Template"
|
||||
3. Choose a template and enter a note name
|
||||
4. The new note will be created with placeholders replaced
|
||||
|
||||
### Built-in Templates
|
||||
- **meeting-notes** - Template for meeting notes
|
||||
- **daily-journal** - Daily journal with morning goals and evening reflection
|
||||
- **project-plan** - Project planning template with objectives and timeline
|
||||
|
||||
## ⚡ Keyboard Shortcuts
|
||||
|
||||
### General
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ function noteApp() {
|
|||
// Dropdown state
|
||||
showNewDropdown: false,
|
||||
|
||||
// Template state
|
||||
showTemplateModal: false,
|
||||
availableTemplates: [],
|
||||
selectedTemplate: '',
|
||||
newTemplateNoteName: '',
|
||||
|
||||
// Homepage state
|
||||
selectedHomepageFolder: '',
|
||||
_homepageCache: {
|
||||
|
|
@ -258,6 +264,7 @@ function noteApp() {
|
|||
await this.loadThemes();
|
||||
await this.initTheme();
|
||||
await this.loadNotes();
|
||||
await this.loadTemplates();
|
||||
await this.checkStatsPlugin();
|
||||
this.loadSidebarWidth();
|
||||
this.loadEditorWidth();
|
||||
|
|
@ -591,6 +598,71 @@ function noteApp() {
|
|||
this.applyFilters();
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Template Methods
|
||||
// ========================================================================
|
||||
|
||||
// Load available templates from _templates folder
|
||||
async loadTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
const data = await response.json();
|
||||
this.availableTemplates = data.templates || [];
|
||||
} catch (error) {
|
||||
ErrorHandler.handle('load templates', error, false); // Don't show alert, templates are optional
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new note from a template
|
||||
async createNoteFromTemplate() {
|
||||
if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine the note path based on current context
|
||||
let notePath = this.newTemplateNoteName.trim();
|
||||
if (!notePath.endsWith('.md')) {
|
||||
notePath += '.md';
|
||||
}
|
||||
|
||||
// If we're in a folder on the homepage, create in that folder
|
||||
if (this.selectedHomepageFolder) {
|
||||
notePath = `${this.selectedHomepageFolder}/${notePath}`;
|
||||
}
|
||||
|
||||
// Create note from template
|
||||
const response = await fetch('/api/templates/create-note', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateName: this.selectedTemplate,
|
||||
notePath: notePath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to create note from template');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Close modal and reset state
|
||||
this.showTemplateModal = false;
|
||||
this.selectedTemplate = '';
|
||||
this.newTemplateNoteName = '';
|
||||
|
||||
// Reload notes and open the new note
|
||||
await this.loadNotes();
|
||||
await this.loadNote(data.path);
|
||||
|
||||
} catch (error) {
|
||||
ErrorHandler.handle('create note from template', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all tag filters
|
||||
clearTagFilters() {
|
||||
this.selectedTags = [];
|
||||
|
|
|
|||
|
|
@ -944,7 +944,17 @@
|
|||
>
|
||||
<span class="text-lg">📁</span>
|
||||
<span>New Folder</span>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="showTemplateModal = true; showNewDropdown = false"
|
||||
class="w-full px-4 py-2.5 text-sm text-left flex items-center gap-3 transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||
onmouseout="this.style.backgroundColor='transparent'"
|
||||
>
|
||||
<span class="text-lg">📄</span>
|
||||
<span>New from Template</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1618,6 +1628,91 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Selection Modal -->
|
||||
<div x-show="showTemplateModal"
|
||||
@click.self="showTemplateModal = false; selectedTemplate = ''; newTemplateNoteName = ''"
|
||||
class="fixed inset-0 flex items-center justify-center z-50"
|
||||
style="background-color: rgba(0, 0, 0, 0.5);"
|
||||
x-cloak>
|
||||
<div class="rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||
@click.stop>
|
||||
|
||||
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary);">Create from Template</h2>
|
||||
|
||||
<!-- Template Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">
|
||||
Select Template
|
||||
</label>
|
||||
<select
|
||||
x-model="selectedTemplate"
|
||||
class="w-full px-3 py-2 rounded border"
|
||||
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
|
||||
>
|
||||
<option value="">-- Choose a template --</option>
|
||||
<template x-for="template in availableTemplates" :key="template.name">
|
||||
<option :value="template.name" x-text="template.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Note Name Input -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">
|
||||
Note Name
|
||||
</label>
|
||||
<input
|
||||
x-model="newTemplateNoteName"
|
||||
type="text"
|
||||
placeholder="Enter note name"
|
||||
class="w-full px-3 py-2 rounded border"
|
||||
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
|
||||
@keydown.enter="createNoteFromTemplate()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info about placeholders -->
|
||||
<div class="mb-4 text-xs p-3 rounded" style="background-color: var(--bg-tertiary); color: var(--text-secondary);">
|
||||
<strong>Available placeholders:</strong><br>
|
||||
<code style="font-family: monospace;">{{date}}</code>,
|
||||
<code style="font-family: monospace;">{{time}}</code>,
|
||||
<code style="font-family: monospace;">{{datetime}}</code>,
|
||||
<code style="font-family: monospace;">{{title}}</code>,
|
||||
<code style="font-family: monospace;">{{folder}}</code>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when no templates available -->
|
||||
<div x-show="availableTemplates.length === 0" class="mb-4 text-sm p-3 rounded" style="background-color: var(--bg-tertiary); color: var(--text-secondary);">
|
||||
No templates found. Create templates in the <code style="font-family: monospace;">_templates</code> folder.
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="createNoteFromTemplate()"
|
||||
:disabled="!selectedTemplate || !newTemplateNoteName.trim() || availableTemplates.length === 0"
|
||||
class="flex-1 px-4 py-2 rounded font-medium text-white transition-colors"
|
||||
style="background-color: var(--accent-primary);"
|
||||
:style="(!selectedTemplate || !newTemplateNoteName.trim() || availableTemplates.length === 0) ? 'opacity: 0.5; cursor: not-allowed;' : ''"
|
||||
onmouseover="if(!this.disabled) this.style.backgroundColor='var(--accent-hover)'"
|
||||
onmouseout="if(!this.disabled) this.style.backgroundColor='var(--accent-primary)'"
|
||||
>
|
||||
Create Note
|
||||
</button>
|
||||
<button
|
||||
@click="showTemplateModal = false; selectedTemplate = ''; newTemplateNoteName = ''"
|
||||
class="flex-1 px-4 py-2 rounded font-medium transition-colors"
|
||||
style="background-color: var(--bg-tertiary); color: var(--text-primary);"
|
||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--bg-tertiary)'"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue