Add File Download for notes.

This commit is contained in:
2025-07-06 22:46:31 +02:00
parent 9113dc1a69
commit f4b8664fd5
3 changed files with 527 additions and 6 deletions

281
app.py
View File

@@ -14,6 +14,7 @@ from datetime import datetime, time, timedelta
import os import os
import csv import csv
import io import io
import re
import pandas as pd import pandas as pd
from sqlalchemy import func from sqlalchemy import func
from functools import wraps from functools import wraps
@@ -1915,6 +1916,286 @@ def view_note_mindmap(slug):
note=note) 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']) @app.route('/notes/<slug>/edit', methods=['GET', 'POST'])
@login_required @login_required
@company_required @company_required

View File

@@ -22,6 +22,38 @@
</div> </div>
<div class="note-actions"> <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"> <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;"> <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"/> <circle cx="8" cy="8" r="2"/>
@@ -452,6 +484,64 @@
font-weight: 500; 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 styles */
.modal-content { .modal-content {
max-width: 500px; max-width: 500px;
@@ -486,8 +576,22 @@
</style> </style>
<script> <script>
{% if note.can_user_edit(g.user) %}
document.addEventListener('DOMContentLoaded', function() { 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 modal = document.getElementById('link-modal');
const addLinkBtn = document.getElementById('add-link-btn'); const addLinkBtn = document.getElementById('add-link-btn');
const closeBtn = modal.querySelector('.close'); const closeBtn = modal.querySelector('.close');
@@ -582,8 +686,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
}); });
{% endif %}
}); });
{% endif %}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -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"/> <path d="M6.5 6.5L4 4M9.5 6.5L12 4M6.5 9.5L4 12M9.5 9.5L12 12" stroke="currentColor" fill="none"/>
</svg> </svg>
</a> </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) %} {% if note.can_user_edit(g.user) %}
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn-action" title="Edit"> <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"> <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"/> <path d="M6.5 6.5L4 4M9.5 6.5L12 4M6.5 9.5L4 12M9.5 9.5L12 12" stroke="currentColor" fill="none"/>
</svg> </svg>
</a> </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) %} {% if note.can_user_edit(g.user) %}
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn-action" title="Edit"> <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"> <svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
@@ -379,6 +391,13 @@
</div> <!-- End notes-layout --> </div> <!-- End notes-layout -->
</div> <!-- End notes-list-container --> </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> <style>
/* Notes list specific styles */ /* Notes list specific styles */
.notes-list-container { .notes-list-container {
@@ -544,6 +563,55 @@
margin-left: 0.5rem; 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 { .folder-children {
margin-left: 1.5rem; margin-left: 1.5rem;
display: none; display: none;
@@ -1026,7 +1094,7 @@
} }
.column-actions { .column-actions {
width: 160px; width: 200px;
} }
.note-actions { .note-actions {
@@ -1460,6 +1528,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Enable drag and drop for notes // Enable drag and drop for notes
enableDragAndDrop(); enableDragAndDrop();
// Setup folder download dropdown
setupFolderDownload();
}); });
// Folder tree functions // Folder tree functions
@@ -1784,6 +1855,63 @@ function addTagsToNotes() {
alert('Error adding tags to notes'); 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> </script>
{% endblock %} {% endblock %}
@@ -1791,13 +1919,21 @@ function addTagsToNotes() {
{% macro render_folder_tree(tree, level=0) %} {% macro render_folder_tree(tree, level=0) %}
{% for folder, children in tree.items() %} {% for folder, children in tree.items() %}
<div class="folder-item {% if children %}has-children{% endif %}" data-folder="{{ folder }}"> <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 %} {% if children %}
<span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;"></span> <span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;"></span>
{% endif %} {% endif %}
<span class="folder-icon">📁</span> <span class="folder-icon" onclick="filterByFolder('{{ folder }}')" style="cursor: pointer;">📁</span>
<span class="folder-name">{{ folder.split('/')[-1] }}</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> <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> </div>
{% if children %} {% if children %}
<div class="folder-children"> <div class="folder-children">