Move all Notes related parts into own modules.

This commit is contained in:
2025-07-06 23:22:20 +02:00
parent 60020e32f6
commit e12681cb08
13 changed files with 1309 additions and 1216 deletions

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package initialization

76
routes/auth.py Normal file
View File

@@ -0,0 +1,76 @@
# Standard library imports
from functools import wraps
# Third-party imports
from flask import flash, g, redirect, request, url_for
# Local application imports
from models import Company, Role, User
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def company_required(f):
"""
Decorator to ensure user has a valid company association and set company context.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login', next=request.url))
# System admins can access without company association
if g.user.role == Role.SYSTEM_ADMIN:
return f(*args, **kwargs)
if g.user.company_id is None:
flash('You must be associated with a company to access this page.', 'error')
return redirect(url_for('setup_company'))
# Set company context
g.company = Company.query.get(g.user.company_id)
if not g.company or not g.company.is_active:
flash('Your company account is inactive.', 'error')
return redirect(url_for('home'))
return f(*args, **kwargs)
return decorated_function
def role_required(*allowed_roles):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user.role not in allowed_roles:
flash('You do not have permission to access this page.', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
flash('Admin access required.', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
def system_admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user.role != Role.SYSTEM_ADMIN:
flash('System admin access required.', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function

491
routes/notes.py Normal file
View File

@@ -0,0 +1,491 @@
# Standard library imports
from datetime import datetime, timezone
# Third-party imports
from flask import (Blueprint, abort, flash, g, jsonify, redirect,
render_template, request, url_for)
from sqlalchemy import and_, or_
# Local application imports
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
Task, db)
from routes.auth import company_required, login_required
# Create blueprint
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
@notes_bp.route('')
@login_required
@company_required
def notes_list():
"""List all notes with optional filtering"""
import logging
logger = logging.getLogger(__name__)
logger.info("Notes list route called")
# Get filter parameters
folder_filter = request.args.get('folder', '')
tag_filter = request.args.get('tag', '')
visibility_filter = request.args.get('visibility', '')
search_query = request.args.get('search', '')
# Base query - only non-archived notes for the user's company
query = Note.query.filter_by(
company_id=g.user.company_id,
is_archived=False
)
# Apply folder filter
if folder_filter:
query = query.filter_by(folder=folder_filter)
# Apply tag filter
if tag_filter:
query = query.filter(Note.tags.contains(tag_filter))
# Apply visibility filter
if visibility_filter:
if visibility_filter == 'private':
query = query.filter_by(visibility=NoteVisibility.PRIVATE, created_by_id=g.user.id)
elif visibility_filter == 'team':
query = query.filter(
and_(
Note.visibility == NoteVisibility.TEAM,
or_(
Note.created_by.has(team_id=g.user.team_id),
Note.created_by_id == g.user.id
)
)
)
elif visibility_filter == 'company':
query = query.filter_by(visibility=NoteVisibility.COMPANY)
else:
# Default visibility filtering - show notes user can see
query = query.filter(
or_(
# Private notes created by user
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
# Team notes from user's team
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
# Company notes
Note.visibility == NoteVisibility.COMPANY
)
)
# Apply search filter
if search_query:
search_pattern = f'%{search_query}%'
query = query.filter(
or_(
Note.title.ilike(search_pattern),
Note.content.ilike(search_pattern),
Note.tags.ilike(search_pattern)
)
)
# Order by pinned first, then by updated date
notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all()
# Get all folders for the sidebar
all_folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Build folder tree structure
folder_tree = {}
folder_counts = {}
# Count notes per folder
folder_note_counts = db.session.query(
Note.folder, db.func.count(Note.id)
).filter_by(
company_id=g.user.company_id,
is_archived=False
).group_by(Note.folder).all()
for folder, count in folder_note_counts:
if folder:
folder_counts[folder] = count
# Build folder tree structure
for folder in all_folders:
parts = folder.path.split('/')
current = folder_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Leaf folder - use full path as key
current[folder.path] = {}
else:
# Navigate to parent using full path
parent_path = '/'.join(parts[:i+1])
if parent_path not in current:
current[parent_path] = {}
current = current[parent_path]
# Get all unique tags
all_notes_for_tags = Note.query.filter_by(
company_id=g.user.company_id,
is_archived=False
).all()
all_tags = set()
tag_counts = {}
for note in all_notes_for_tags:
if note.tags:
note_tags = note.get_tags_list()
for tag in note_tags:
all_tags.add(tag)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
all_tags = sorted(list(all_tags))
# Count notes by visibility
visibility_counts = {
'private': Note.query.filter_by(
company_id=g.user.company_id,
visibility=NoteVisibility.PRIVATE,
created_by_id=g.user.id,
is_archived=False
).count(),
'team': Note.query.filter(
Note.company_id == g.user.company_id,
Note.visibility == NoteVisibility.TEAM,
Note.created_by.has(team_id=g.user.team_id),
Note.is_archived == False
).count(),
'company': Note.query.filter_by(
company_id=g.user.company_id,
visibility=NoteVisibility.COMPANY,
is_archived=False
).count()
}
try:
logger.info(f"Rendering template with {len(notes)} notes, folder_tree type: {type(folder_tree)}")
return render_template('notes_list.html',
notes=notes,
folder_tree=folder_tree,
folder_counts=folder_counts,
all_tags=all_tags,
tag_counts=tag_counts,
visibility_counts=visibility_counts,
folder_filter=folder_filter,
tag_filter=tag_filter,
visibility_filter=visibility_filter,
search_query=search_query,
title='Notes')
except Exception as e:
logger.error(f"Error rendering notes template: {str(e)}", exc_info=True)
raise
@notes_bp.route('/new', methods=['GET', 'POST'])
@login_required
@company_required
def create_note():
"""Create a new note"""
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
# Validate
if not title:
flash('Title is required', 'error')
return redirect(url_for('notes.create_note'))
if not content:
flash('Content is required', 'error')
return redirect(url_for('notes.create_note'))
# Validate visibility
try:
visibility_enum = NoteVisibility[visibility.upper()]
except KeyError:
visibility_enum = NoteVisibility.PRIVATE
# Validate project if provided
project = None
if project_id:
project = Project.query.filter_by(
id=project_id,
company_id=g.user.company_id
).first()
if not project:
flash('Invalid project selected', 'error')
return redirect(url_for('notes.create_note'))
# Validate task if provided
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
if not task or (project and task.project_id != project.id):
flash('Invalid task selected', 'error')
return redirect(url_for('notes.create_note'))
# Create note
note = Note(
title=title,
content=content,
visibility=visibility_enum,
folder=folder if folder else None,
tags=tags if tags else None,
company_id=g.user.company_id,
created_by_id=g.user.id,
project_id=project.id if project else None,
task_id=task.id if task else None
)
db.session.add(note)
db.session.commit()
flash('Note created successfully', 'success')
return redirect(url_for('notes.view_note', slug=note.slug))
# GET request - show form
# Get folders for dropdown
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_archived=False
).order_by(Project.name).all()
# Get task if specified in URL
task_id = request.args.get('task_id')
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
return render_template('note_editor.html',
folders=folders,
projects=projects,
task=task,
title='Create Note')
@notes_bp.route('/<slug>')
@login_required
@company_required
def view_note(slug):
"""View a note"""
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)
# Get linked notes
outgoing_links = NoteLink.query.filter_by(
source_note_id=note.id
).join(
Note, NoteLink.target_note_id == Note.id
).filter(
Note.is_archived == False
).all()
incoming_links = NoteLink.query.filter_by(
target_note_id=note.id
).join(
Note, NoteLink.source_note_id == Note.id
).filter(
Note.is_archived == False
).all()
# Get linkable notes for the modal
linkable_notes = Note.query.filter(
Note.company_id == g.user.company_id,
Note.id != note.id,
Note.is_archived == False
).filter(
or_(
# User's private notes
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
# Team notes
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
# Company notes
Note.visibility == NoteVisibility.COMPANY
)
).order_by(Note.title).all()
return render_template('note_view.html',
note=note,
outgoing_links=outgoing_links,
incoming_links=incoming_links,
linkable_notes=linkable_notes,
title=note.title)
@notes_bp.route('/<slug>/mindmap')
@login_required
@company_required
def view_note_mindmap(slug):
"""View a note as a mind map"""
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)
return render_template('note_mindmap.html', note=note, title=f"{note.title} - Mind Map")
@notes_bp.route('/<slug>/edit', methods=['GET', 'POST'])
@login_required
@company_required
def edit_note(slug):
"""Edit an existing note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
abort(403)
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
# Validate
if not title:
flash('Title is required', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
if not content:
flash('Content is required', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Validate visibility
try:
visibility_enum = NoteVisibility[visibility.upper()]
except KeyError:
visibility_enum = NoteVisibility.PRIVATE
# Validate project if provided
project = None
if project_id:
project = Project.query.filter_by(
id=project_id,
company_id=g.user.company_id
).first()
if not project:
flash('Invalid project selected', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Validate task if provided
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
if not task or (project and task.project_id != project.id):
flash('Invalid task selected', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Update note
note.title = title
note.content = content
note.visibility = visibility_enum
note.folder = folder if folder else None
note.tags = tags if tags else None
note.project_id = project.id if project else None
note.task_id = task.id if task else None
note.updated_at = datetime.now(timezone.utc)
# Update slug if title changed
note.generate_slug()
db.session.commit()
flash('Note updated successfully', 'success')
return redirect(url_for('notes.view_note', slug=note.slug))
# GET request - show form
# Get folders for dropdown
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_archived=False
).order_by(Project.name).all()
return render_template('note_editor.html',
note=note,
folders=folders,
projects=projects,
title=f'Edit {note.title}')
@notes_bp.route('/<slug>/delete', methods=['POST'])
@login_required
@company_required
def delete_note(slug):
"""Delete (archive) a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
abort(403)
# Archive the note
note.is_archived = True
note.updated_at = datetime.utcnow()
db.session.commit()
flash('Note deleted successfully', 'success')
return redirect(url_for('notes.notes_list'))
@notes_bp.route('/folders')
@login_required
@company_required
def notes_folders():
"""Manage note folders"""
# Get all folders
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get note counts per folder
folder_counts = {}
folder_note_counts = db.session.query(
Note.folder, db.func.count(Note.id)
).filter_by(
company_id=g.user.company_id,
is_archived=False
).filter(Note.folder.isnot(None)).group_by(Note.folder).all()
for folder, count in folder_note_counts:
folder_counts[folder] = count
# Build folder tree
folder_tree = {}
for folder in folders:
parts = folder.path.split('/')
current = folder_tree
for part in parts:
if part not in current:
current[part] = {}
current = current[part]
return render_template('notes_folders.html',
folders=folders,
folder_tree=folder_tree,
folder_counts=folder_counts,
title='Manage Folders')

388
routes/notes_api.py Normal file
View File

@@ -0,0 +1,388 @@
# Standard library imports
from datetime import datetime, timezone
# Third-party imports
from flask import Blueprint, abort, g, jsonify, request
from sqlalchemy import and_, or_
# Local application imports
from models import Note, NoteFolder, NoteLink, NoteVisibility, db
from routes.auth import company_required, login_required
# Create blueprint
notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes')
@notes_api_bp.route('/folder-details')
@login_required
@company_required
def api_folder_details():
"""Get folder details including note count"""
folder_path = request.args.get('folder', '')
# Get note count for this folder
note_count = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).count()
# Check if folder exists in NoteFolder table
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=folder_path
).first()
return jsonify({
'success': True,
'folder': {
'path': folder_path,
'name': folder_path.split('/')[-1] if folder_path else 'Root',
'exists': folder is not None,
'note_count': note_count,
'created_at': folder.created_at.isoformat() if folder else None
}
})
@notes_api_bp.route('/folders', methods=['POST'])
@login_required
@company_required
def api_create_folder():
"""Create a new folder"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': 'No data provided'}), 400
folder_name = data.get('name', '').strip()
parent_path = data.get('parent', '').strip()
if not folder_name:
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
# Validate folder name
if '/' in folder_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Build full path
if parent_path:
full_path = f"{parent_path}/{folder_name}"
else:
full_path = folder_name
# Check if folder already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=full_path
).first()
if existing:
return jsonify({'success': False, 'message': 'Folder already exists'}), 400
# Create folder
folder = NoteFolder(
path=full_path,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(folder)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder created successfully',
'folder': {
'path': folder.path,
'name': folder_name
}
})
@notes_api_bp.route('/folders', methods=['PUT'])
@login_required
@company_required
def api_rename_folder():
"""Rename a folder"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': 'No data provided'}), 400
old_path = data.get('old_path', '').strip()
new_name = data.get('new_name', '').strip()
if not old_path or not new_name:
return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400
# Validate new name
if '/' in new_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Find the folder
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=old_path
).first()
if not folder:
return jsonify({'success': False, 'message': 'Folder not found'}), 404
# Build new path
path_parts = old_path.split('/')
path_parts[-1] = new_name
new_path = '/'.join(path_parts)
# Check if new path already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=new_path
).first()
if existing:
return jsonify({'success': False, 'message': 'A folder with this name already exists'}), 400
# Update folder path
folder.path = new_path
# Update all notes in this folder
Note.query.filter_by(
company_id=g.user.company_id,
folder=old_path
).update({Note.folder: new_path})
# Update all subfolders
subfolders = NoteFolder.query.filter(
NoteFolder.company_id == g.user.company_id,
NoteFolder.path.like(f"{old_path}/%")
).all()
for subfolder in subfolders:
subfolder.path = subfolder.path.replace(old_path, new_path, 1)
# Update all notes in subfolders
notes_in_subfolders = Note.query.filter(
Note.company_id == g.user.company_id,
Note.folder.like(f"{old_path}/%")
).all()
for note in notes_in_subfolders:
note.folder = note.folder.replace(old_path, new_path, 1)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder renamed successfully',
'folder': {
'old_path': old_path,
'new_path': new_path
}
})
@notes_api_bp.route('/folders', methods=['DELETE'])
@login_required
@company_required
def api_delete_folder():
"""Delete a folder"""
folder_path = request.args.get('path', '').strip()
if not folder_path:
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
# Check if folder has notes
note_count = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).count()
if note_count > 0:
return jsonify({
'success': False,
'message': f'Cannot delete folder with {note_count} notes. Please move or delete the notes first.'
}), 400
# Find and delete the folder
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=folder_path
).first()
if folder:
db.session.delete(folder)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder deleted successfully'
})
@notes_api_bp.route('/<slug>/folder', methods=['PUT'])
@login_required
@company_required
def update_note_folder(slug):
"""Update a note's folder via drag and drop"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
new_folder = data.get('folder', '').strip()
# Update note folder
note.folder = new_folder if new_folder else None
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Note moved successfully'
})
@notes_api_bp.route('/<int:note_id>/tags', methods=['POST'])
@login_required
@company_required
def add_tags_to_note(note_id):
"""Add tags to a note"""
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'message': 'Note not found'}), 404
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
new_tags = data.get('tags', '').strip()
if not new_tags:
return jsonify({'success': False, 'message': 'No tags provided'}), 400
# Merge with existing tags
existing_tags = note.get_tags_list()
new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()]
# Combine and deduplicate
all_tags = list(set(existing_tags + new_tag_list))
# Update note
note.tags = ', '.join(sorted(all_tags))
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Tags added successfully',
'tags': all_tags
})
@notes_api_bp.route('/<int:note_id>/link', methods=['POST'])
@login_required
@company_required
def link_notes(note_id):
"""Create a link between two notes"""
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not source_note:
return jsonify({'success': False, 'message': 'Source note not found'}), 404
# Check permissions
if not source_note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
target_note_id = data.get('target_note_id')
link_type = data.get('link_type', 'related')
if not target_note_id:
return jsonify({'success': False, 'message': 'Target note ID is required'}), 400
# Get target note
target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first()
if not target_note:
return jsonify({'success': False, 'message': 'Target note not found'}), 404
# Check if user can view target note
if not target_note.can_user_view(g.user):
return jsonify({'success': False, 'message': 'You cannot link to a note you cannot view'}), 403
# Check if link already exists
existing_link = NoteLink.query.filter_by(
source_note_id=source_note.id,
target_note_id=target_note.id
).first()
if existing_link:
return jsonify({'success': False, 'message': 'Link already exists'}), 400
# Create link
link = NoteLink(
source_note_id=source_note.id,
target_note_id=target_note.id,
link_type=link_type,
created_by_id=g.user.id
)
db.session.add(link)
db.session.commit()
return jsonify({
'success': True,
'message': 'Notes linked successfully'
})
@notes_api_bp.route('/<int:note_id>/link', methods=['DELETE'])
@login_required
@company_required
def unlink_notes(note_id):
"""Remove a link between two notes"""
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not source_note:
return jsonify({'success': False, 'message': 'Source note not found'}), 404
# Check permissions
if not source_note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
target_note_id = data.get('target_note_id')
if not target_note_id:
return jsonify({'success': False, 'message': 'Target note ID is required'}), 400
# Find and delete the link (check both directions)
link = NoteLink.query.filter(
or_(
and_(
NoteLink.source_note_id == source_note.id,
NoteLink.target_note_id == target_note_id
),
and_(
NoteLink.source_note_id == target_note_id,
NoteLink.target_note_id == source_note.id
)
)
).first()
if not link:
return jsonify({'success': False, 'message': 'Link not found'}), 404
db.session.delete(link)
db.session.commit()
return jsonify({
'success': True,
'message': 'Link removed successfully'
})

288
routes/notes_download.py Normal file
View File

@@ -0,0 +1,288 @@
# Standard library imports
import os
import re
import tempfile
import zipfile
from datetime import datetime
from urllib.parse import unquote
# Third-party imports
from flask import (Blueprint, Response, abort, flash, g, redirect, request,
send_file, url_for)
# Local application imports
from frontmatter_utils import parse_frontmatter
from models import Note, db
from routes.auth import company_required, login_required
# Create blueprint
notes_download_bp = Blueprint('notes_download', __name__)
@notes_download_bp.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
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)
@notes_download_bp.route('/notes/download-bulk', methods=['POST'])
@login_required
@company_required
def download_notes_bulk():
"""Download multiple notes as a zip file"""
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.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
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
os.unlink(temp_file.name)
@notes_download_bp.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"""
# Decode folder path (replace URL encoding)
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.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
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)