Add File Download for notes.
This commit is contained in:
281
app.py
281
app.py
@@ -14,6 +14,7 @@ from datetime import datetime, time, timedelta
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import pandas as pd
|
||||
from sqlalchemy import func
|
||||
from functools import wraps
|
||||
@@ -1915,6 +1916,286 @@ def view_note_mindmap(slug):
|
||||
note=note)
|
||||
|
||||
|
||||
@app.route('/notes/<slug>/download/<format>')
|
||||
@login_required
|
||||
@company_required
|
||||
def download_note(slug, format):
|
||||
"""Download a note in various formats"""
|
||||
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
||||
|
||||
# Check permissions
|
||||
if not note.can_user_view(g.user):
|
||||
abort(403)
|
||||
|
||||
# Prepare filename
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||
timestamp = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
if format == 'md':
|
||||
# Download as Markdown with frontmatter
|
||||
content = note.content
|
||||
response = Response(content, mimetype='text/markdown')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.md"'
|
||||
return response
|
||||
|
||||
elif format == 'html':
|
||||
# Download as HTML
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{note.title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
||||
h1, h2, h3 {{ margin-top: 2rem; }}
|
||||
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
||||
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
||||
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
||||
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
|
||||
.metadata dl {{ margin: 0; }}
|
||||
.metadata dt {{ font-weight: bold; display: inline-block; width: 120px; }}
|
||||
.metadata dd {{ display: inline; margin: 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="metadata">
|
||||
<h1>{note.title}</h1>
|
||||
<dl>
|
||||
<dt>Author:</dt><dd>{note.created_by.username}</dd><br>
|
||||
<dt>Created:</dt><dd>{note.created_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
|
||||
<dt>Updated:</dt><dd>{note.updated_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
|
||||
<dt>Visibility:</dt><dd>{note.visibility.value}</dd><br>
|
||||
{'<dt>Folder:</dt><dd>' + note.folder + '</dd><br>' if note.folder else ''}
|
||||
{'<dt>Tags:</dt><dd>' + note.tags + '</dd><br>' if note.tags else ''}
|
||||
</dl>
|
||||
</div>
|
||||
{note.render_html()}
|
||||
</body>
|
||||
</html>"""
|
||||
response = Response(html_content, mimetype='text/html')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.html"'
|
||||
return response
|
||||
|
||||
elif format == 'txt':
|
||||
# Download as plain text
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, body = parse_frontmatter(note.content)
|
||||
|
||||
# Create plain text version
|
||||
text_content = f"{note.title}\n{'=' * len(note.title)}\n\n"
|
||||
text_content += f"Author: {note.created_by.username}\n"
|
||||
text_content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
|
||||
text_content += f"Updated: {note.updated_at.strftime('%Y-%m-%d %H:%M')}\n"
|
||||
text_content += f"Visibility: {note.visibility.value}\n"
|
||||
if note.folder:
|
||||
text_content += f"Folder: {note.folder}\n"
|
||||
if note.tags:
|
||||
text_content += f"Tags: {note.tags}\n"
|
||||
text_content += "\n" + "-" * 40 + "\n\n"
|
||||
|
||||
# Remove markdown formatting
|
||||
text_body = body
|
||||
# Remove headers markdown
|
||||
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
|
||||
# Remove emphasis
|
||||
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
|
||||
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
|
||||
# Remove links but keep text
|
||||
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
|
||||
# Remove images
|
||||
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
|
||||
# Remove code blocks markers
|
||||
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
|
||||
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
|
||||
|
||||
text_content += text_body
|
||||
|
||||
response = Response(text_content, mimetype='text/plain')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.txt"'
|
||||
return response
|
||||
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route('/notes/download-bulk', methods=['POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def download_notes_bulk():
|
||||
"""Download multiple notes as a zip file"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
note_ids = request.form.getlist('note_ids[]')
|
||||
format = request.form.get('format', 'md')
|
||||
|
||||
if not note_ids:
|
||||
flash('No notes selected for download', 'error')
|
||||
return redirect(url_for('notes_list'))
|
||||
|
||||
# Create a temporary file for the zip
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
|
||||
for note_id in note_ids:
|
||||
note = Note.query.filter_by(id=int(note_id), company_id=g.user.company_id).first()
|
||||
if note and note.can_user_view(g.user):
|
||||
# Get content based on format
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||
|
||||
if format == 'md':
|
||||
content = note.content
|
||||
filename = f"{safe_filename}.md"
|
||||
elif format == 'html':
|
||||
content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{note.title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
||||
h1, h2, h3 {{ margin-top: 2rem; }}
|
||||
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
||||
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
||||
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{note.title}</h1>
|
||||
{note.render_html()}
|
||||
</body>
|
||||
</html>"""
|
||||
filename = f"{safe_filename}.html"
|
||||
else: # txt
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, body = parse_frontmatter(note.content)
|
||||
content = f"{note.title}\n{'=' * len(note.title)}\n\n{body}"
|
||||
filename = f"{safe_filename}.txt"
|
||||
|
||||
# Add file to zip
|
||||
zipf.writestr(filename, content)
|
||||
|
||||
# Send the zip file
|
||||
temp_file.seek(0)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
return send_file(
|
||||
temp_file.name,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f'notes_{timestamp}.zip'
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up temp file after sending
|
||||
import os
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
@app.route('/notes/folder/<path:folder_path>/download/<format>')
|
||||
@login_required
|
||||
@company_required
|
||||
def download_folder(folder_path, format):
|
||||
"""Download all notes in a folder as a zip file"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
# Decode folder path (replace URL encoding)
|
||||
from urllib.parse import unquote
|
||||
folder_path = unquote(folder_path)
|
||||
|
||||
# Get all notes in this folder
|
||||
notes = Note.query.filter_by(
|
||||
company_id=g.user.company_id,
|
||||
folder=folder_path,
|
||||
is_archived=False
|
||||
).all()
|
||||
|
||||
# Filter notes user can view
|
||||
viewable_notes = [note for note in notes if note.can_user_view(g.user)]
|
||||
|
||||
if not viewable_notes:
|
||||
flash('No notes found in this folder or you don\'t have permission to view them.', 'warning')
|
||||
return redirect(url_for('notes_list'))
|
||||
|
||||
# Create a temporary file for the zip
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
|
||||
for note in viewable_notes:
|
||||
# Get content based on format
|
||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||
|
||||
if format == 'md':
|
||||
content = note.content
|
||||
filename = f"{safe_filename}.md"
|
||||
elif format == 'html':
|
||||
content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{note.title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
||||
h1, h2, h3 {{ margin-top: 2rem; }}
|
||||
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
||||
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
||||
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
||||
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="metadata">
|
||||
<h1>{note.title}</h1>
|
||||
<p>Author: {note.created_by.username} | Created: {note.created_at.strftime('%Y-%m-%d %H:%M')} | Folder: {note.folder}</p>
|
||||
</div>
|
||||
{note.render_html()}
|
||||
</body>
|
||||
</html>"""
|
||||
filename = f"{safe_filename}.html"
|
||||
else: # txt
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, body = parse_frontmatter(note.content)
|
||||
# Remove markdown formatting
|
||||
text_body = body
|
||||
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
|
||||
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
|
||||
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
|
||||
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
|
||||
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
|
||||
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
|
||||
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
|
||||
|
||||
content = f"{note.title}\n{'=' * len(note.title)}\n\n"
|
||||
content += f"Author: {note.created_by.username}\n"
|
||||
content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
|
||||
content += f"Folder: {note.folder}\n\n"
|
||||
content += "-" * 40 + "\n\n"
|
||||
content += text_body
|
||||
filename = f"{safe_filename}.txt"
|
||||
|
||||
# Add file to zip
|
||||
zipf.writestr(filename, content)
|
||||
|
||||
# Send the zip file
|
||||
temp_file.seek(0)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safe_folder_name = re.sub(r'[^a-zA-Z0-9_-]', '_', folder_path.replace('/', '_'))
|
||||
|
||||
return send_file(
|
||||
temp_file.name,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f'{safe_folder_name}_notes_{timestamp}.zip'
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up temp file after sending
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
@app.route('/notes/<slug>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
|
||||
@@ -22,6 +22,38 @@
|
||||
</div>
|
||||
|
||||
<div class="note-actions">
|
||||
<div class="dropdown" style="display: inline-block;">
|
||||
<button class="btn btn-success dropdown-toggle" type="button" id="downloadDropdown" data-toggle="dropdown">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: -2px; margin-right: 4px;">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="{{ url_for('download_note', slug=note.slug, format='md') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="margin-right: 8px;">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
||||
<path d="M4.5 12.5A.5.5 0 0 1 5 12h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm0-2A.5.5 0 0 1 5 10h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm1.639-3.708 1.33.886 1.854-1.855a.25.25 0 0 1 .289-.047l1.888.974V8.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V7s1.54-1.274 1.639-1.208zM6.25 6a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z"/>
|
||||
</svg>
|
||||
Markdown (.md)
|
||||
</a>
|
||||
<a class="dropdown-item" href="{{ url_for('download_note', slug=note.slug, format='html') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="margin-right: 8px;">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
||||
<path d="M8.5 6.5a.5.5 0 0 0-1 0V8H6a.5.5 0 0 0 0 1h1.5v1.5a.5.5 0 0 0 1 0V9H10a.5.5 0 0 0 0-1H8.5V6.5z"/>
|
||||
</svg>
|
||||
HTML (.html)
|
||||
</a>
|
||||
<a class="dropdown-item" href="{{ url_for('download_note', slug=note.slug, format='txt') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="margin-right: 8px;">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
Plain Text (.txt)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('view_note_mindmap', slug=note.slug) }}" class="btn btn-info">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: -2px; margin-right: 4px;">
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
@@ -452,6 +484,64 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
margin-left: 0.255em;
|
||||
vertical-align: 0.255em;
|
||||
border-top: 0.3em solid;
|
||||
border-right: 0.3em solid transparent;
|
||||
border-bottom: 0;
|
||||
border-left: 0.3em solid transparent;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0.125rem 0 0;
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #212529;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-content {
|
||||
max-width: 500px;
|
||||
@@ -486,8 +576,22 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Download dropdown functionality
|
||||
const downloadBtn = document.getElementById('downloadDropdown');
|
||||
const downloadMenu = downloadBtn.nextElementSibling;
|
||||
|
||||
downloadBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
downloadMenu.classList.toggle('show');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function() {
|
||||
downloadMenu.classList.remove('show');
|
||||
});
|
||||
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
const modal = document.getElementById('link-modal');
|
||||
const addLinkBtn = document.getElementById('add-link-btn');
|
||||
const closeBtn = modal.querySelector('.close');
|
||||
@@ -582,8 +686,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -258,6 +258,12 @@
|
||||
<path d="M6.5 6.5L4 4M9.5 6.5L12 4M6.5 9.5L4 12M9.5 9.5L12 12" stroke="currentColor" fill="none"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('download_note', slug=note.slug, format='md') }}" class="btn-action" title="Download as Markdown">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn-action" title="Edit">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
@@ -348,6 +354,12 @@
|
||||
<path d="M6.5 6.5L4 4M9.5 6.5L12 4M6.5 9.5L4 12M9.5 9.5L12 12" stroke="currentColor" fill="none"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('download_note', slug=note.slug, format='md') }}" class="btn-action" title="Download as Markdown">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn-action" title="Edit">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
@@ -379,6 +391,13 @@
|
||||
</div> <!-- End notes-layout -->
|
||||
</div> <!-- End notes-list-container -->
|
||||
|
||||
<!-- Folder Download Dropdown (Hidden, positioned dynamically) -->
|
||||
<div id="folder-download-dropdown" class="folder-download-dropdown">
|
||||
<a href="#" data-format="md">Download as Markdown</a>
|
||||
<a href="#" data-format="html">Download as HTML</a>
|
||||
<a href="#" data-format="txt">Download as Text</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Notes list specific styles */
|
||||
.notes-list-container {
|
||||
@@ -544,6 +563,55 @@
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-download-btn {
|
||||
margin-left: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.folder-content:hover .folder-download-btn {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.folder-download-btn:hover {
|
||||
opacity: 1 !important;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Folder download dropdown */
|
||||
.folder-download-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-download-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.folder-download-dropdown a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.folder-download-dropdown a:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
margin-left: 1.5rem;
|
||||
display: none;
|
||||
@@ -1026,7 +1094,7 @@
|
||||
}
|
||||
|
||||
.column-actions {
|
||||
width: 160px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.note-actions {
|
||||
@@ -1460,6 +1528,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Enable drag and drop for notes
|
||||
enableDragAndDrop();
|
||||
|
||||
// Setup folder download dropdown
|
||||
setupFolderDownload();
|
||||
});
|
||||
|
||||
// Folder tree functions
|
||||
@@ -1784,6 +1855,63 @@ function addTagsToNotes() {
|
||||
alert('Error adding tags to notes');
|
||||
});
|
||||
}
|
||||
|
||||
// Folder download functions
|
||||
function setupFolderDownload() {
|
||||
const dropdown = document.getElementById('folder-download-dropdown');
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.folder-download-btn') && !dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Setup download links
|
||||
dropdown.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const format = this.getAttribute('data-format');
|
||||
const folder = dropdown.getAttribute('data-folder');
|
||||
if (folder) {
|
||||
downloadFolder(folder, format);
|
||||
}
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showFolderDownloadMenu(event, folderPath) {
|
||||
event.stopPropagation();
|
||||
const dropdown = document.getElementById('folder-download-dropdown');
|
||||
const btn = event.currentTarget;
|
||||
|
||||
// Position dropdown near the button
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.top = (rect.bottom + 5) + 'px';
|
||||
|
||||
// Store the folder path
|
||||
dropdown.setAttribute('data-folder', folderPath);
|
||||
|
||||
// Show dropdown
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function downloadFolder(folderPath, format) {
|
||||
// Encode the folder path for URL
|
||||
const encodedPath = encodeURIComponent(folderPath);
|
||||
const url = `/notes/folder/${encodedPath}/download/${format}`;
|
||||
|
||||
// Create a temporary link and click it
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1791,13 +1919,21 @@ function addTagsToNotes() {
|
||||
{% macro render_folder_tree(tree, level=0) %}
|
||||
{% for folder, children in tree.items() %}
|
||||
<div class="folder-item {% if children %}has-children{% endif %}" data-folder="{{ folder }}">
|
||||
<div class="folder-content {% if folder_filter == folder %}active{% endif %}" onclick="filterByFolder('{{ folder }}')">
|
||||
<div class="folder-content {% if folder_filter == folder %}active{% endif %}">
|
||||
{% if children %}
|
||||
<span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;">▶</span>
|
||||
{% endif %}
|
||||
<span class="folder-icon">📁</span>
|
||||
<span class="folder-name">{{ folder.split('/')[-1] }}</span>
|
||||
<span class="folder-icon" onclick="filterByFolder('{{ folder }}')" style="cursor: pointer;">📁</span>
|
||||
<span class="folder-name" onclick="filterByFolder('{{ folder }}')" style="cursor: pointer;">{{ folder.split('/')[-1] }}</span>
|
||||
<span class="folder-count">({{ folder_counts.get(folder, 0) }})</span>
|
||||
{% if folder_counts.get(folder, 0) > 0 %}
|
||||
<div class="folder-download-btn" onclick="showFolderDownloadMenu(event, '{{ folder }}')">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if children %}
|
||||
<div class="folder-children">
|
||||
|
||||
Reference in New Issue
Block a user