From f4b8664fd56093a3e32011fcfd80c071f2a44dcc Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 6 Jul 2025 22:46:31 +0200 Subject: [PATCH] Add File Download for notes. --- app.py | 281 ++++++++++++++++++++++++++++++++++++++ templates/note_view.html | 108 ++++++++++++++- templates/notes_list.html | 144 ++++++++++++++++++- 3 files changed, 527 insertions(+), 6 deletions(-) diff --git a/app.py b/app.py index 1f1aa29..36fcf31 100644 --- a/app.py +++ b/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//download/') +@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""" + + + + {note.title} + + + + + {note.render_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""" + + + + {note.title} + + + +

{note.title}

+ {note.render_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//download/') +@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""" + + + + {note.title} + + + + + {note.render_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//edit', methods=['GET', 'POST']) @login_required @company_required diff --git a/templates/note_view.html b/templates/note_view.html index 2d33e0a..040e44d 100644 --- a/templates/note_view.html +++ b/templates/note_view.html @@ -22,6 +22,38 @@
+ @@ -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 @@ {% endblock %} \ No newline at end of file diff --git a/templates/notes_list.html b/templates/notes_list.html index 5a59bbb..379fcba 100644 --- a/templates/notes_list.html +++ b/templates/notes_list.html @@ -258,6 +258,12 @@ + + + + + + {% if note.can_user_edit(g.user) %} @@ -348,6 +354,12 @@ + + + + + + {% if note.can_user_edit(g.user) %} @@ -379,6 +391,13 @@
+ + +