Move all Notes related parts into own modules.
This commit is contained in:
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(docker restart:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(curl:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package initialization
|
||||
76
routes/auth.py
Normal file
76
routes/auth.py
Normal 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
491
routes/notes.py
Normal 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
388
routes/notes_api.py
Normal 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
288
routes/notes_download.py
Normal 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)
|
||||
@@ -132,7 +132,7 @@
|
||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
|
||||
<li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
|
||||
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃♂️</i><span class="nav-text">Sprints</span></a></li>
|
||||
<li><a href="{{ url_for('notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
|
||||
<li><a href="{{ url_for('notes.notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
|
||||
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
||||
|
||||
<!-- Role-based menu items -->
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if note %}Update Note{% else %}Create Note{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<div class="linked-notes-list">
|
||||
{% for link in note.linked_notes %}
|
||||
<div class="linked-note-item">
|
||||
<a href="{{ url_for('view_note', slug=link.target_note.slug) }}">
|
||||
<a href="{{ url_for('notes.view_note', slug=link.target_note.slug) }}">
|
||||
{{ link.target_note.title }}
|
||||
</a>
|
||||
<span class="link-type">{{ link.link_type }}</span>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleFullscreen()">
|
||||
Fullscreen
|
||||
</button>
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}" class="btn btn-sm btn-info">
|
||||
<a href="{{ url_for('notes.view_note', slug=note.slug) }}" class="btn btn-sm btn-info">
|
||||
Back to Note
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -31,21 +31,21 @@
|
||||
Download
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="{{ url_for('download_note', slug=note.slug, format='md') }}">
|
||||
<a class="dropdown-item" href="{{ url_for('notes_download.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') }}">
|
||||
<a class="dropdown-item" href="{{ url_for('notes_download.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') }}">
|
||||
<a class="dropdown-item" href="{{ url_for('notes_download.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"/>
|
||||
@@ -54,7 +54,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('view_note_mindmap', slug=note.slug) }}" class="btn btn-info">
|
||||
<a href="{{ url_for('notes.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"/>
|
||||
<circle cx="3" cy="3" r="1.5"/>
|
||||
@@ -66,13 +66,13 @@
|
||||
Mind Map
|
||||
</a>
|
||||
{% if note.can_user_edit(g.user) %}
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-primary">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary">Edit</a>
|
||||
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Back to Notes</a>
|
||||
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">Back to Notes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<span class="association-label">Tags:</span>
|
||||
<span class="association-value">
|
||||
{% for tag in note.get_tags_list() %}
|
||||
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@
|
||||
{% for link in outgoing_links %}
|
||||
<div class="linked-note-card">
|
||||
<div class="linked-note-header">
|
||||
<h4><a href="{{ url_for('view_note', slug=link.target_note.slug) }}">{{ link.target_note.title }}</a></h4>
|
||||
<h4><a href="{{ url_for('notes.view_note', slug=link.target_note.slug) }}">{{ link.target_note.title }}</a></h4>
|
||||
<span class="link-type">→ {{ link.link_type }}</span>
|
||||
</div>
|
||||
<div class="linked-note-preview">
|
||||
@@ -143,7 +143,7 @@
|
||||
{% for link in incoming_links %}
|
||||
<div class="linked-note-card">
|
||||
<div class="linked-note-header">
|
||||
<h4><a href="{{ url_for('view_note', slug=link.source_note.slug) }}">{{ link.source_note.title }}</a></h4>
|
||||
<h4><a href="{{ url_for('notes.view_note', slug=link.source_note.slug) }}">{{ link.source_note.title }}</a></h4>
|
||||
<span class="link-type">← {{ link.link_type }}</span>
|
||||
</div>
|
||||
<div class="linked-note-preview">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="showCreateFolderModal()">
|
||||
<span>📁</span> Create Folder
|
||||
</button>
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-sm btn-secondary">Back to Notes</a>
|
||||
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-sm btn-secondary">Back to Notes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="toggle-sidebar">
|
||||
<span>📁</span> Toggle Folders
|
||||
</button>
|
||||
<a href="{{ url_for('notes_folders') }}" class="btn btn-sm btn-info">
|
||||
<a href="{{ url_for('notes.notes_folders') }}" class="btn btn-sm btn-info">
|
||||
<span>⚙️</span> Manage Folders
|
||||
</a>
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- Folder Tree Sidebar -->
|
||||
<div class="folder-sidebar" id="folder-sidebar">
|
||||
<div class="create-note-section">
|
||||
<a href="{{ url_for('create_note') }}" class="btn btn-create-note">
|
||||
<a href="{{ url_for('notes.create_note') }}" class="btn btn-create-note">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
||||
</svg>
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="notes-filter-section">
|
||||
<form method="GET" action="{{ url_for('notes_list') }}" class="filter-form" id="filter-form">
|
||||
<form method="GET" action="{{ url_for('notes.notes_list') }}" class="filter-form" id="filter-form">
|
||||
<!-- Hidden inputs for sidebar filters -->
|
||||
<input type="hidden" name="folder" id="folder" value="{{ folder_filter or '' }}">
|
||||
<input type="hidden" name="tag" id="tag" value="{{ tag_filter or '' }}">
|
||||
@@ -202,7 +202,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="column-title">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}" class="note-link">
|
||||
<a href="{{ url_for('notes.view_note', slug=note.slug) }}" class="note-link">
|
||||
{{ note.title }}
|
||||
</a>
|
||||
<div class="note-associations">
|
||||
@@ -234,7 +234,7 @@
|
||||
<td class="column-tags">
|
||||
{% if note.tags %}
|
||||
{% for tag in note.get_tags_list() %}
|
||||
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -243,12 +243,12 @@
|
||||
</td>
|
||||
<td class="column-actions">
|
||||
<div class="note-actions">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}" class="btn-action" title="View">
|
||||
<a href="{{ url_for('notes.view_note', slug=note.slug) }}" class="btn-action" title="View">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('view_note_mindmap', slug=note.slug) }}" class="btn-action" title="Mind Map">
|
||||
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn-action" title="Mind Map">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
<circle cx="3" cy="3" r="1.5"/>
|
||||
@@ -258,19 +258,19 @@
|
||||
<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">
|
||||
<a href="{{ url_for('notes_download.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">
|
||||
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn-action" title="Edit">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||
<button type="submit" class="btn-action btn-action-danger" title="Delete">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
@@ -295,7 +295,7 @@
|
||||
<div class="note-card" data-note-slug="{{ note.slug }}">
|
||||
<div class="note-header">
|
||||
<h3 class="note-title">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}">{{ note.title }}</a>
|
||||
<a href="{{ url_for('notes.view_note', slug=note.slug) }}">{{ note.title }}</a>
|
||||
{% if note.is_pinned %}
|
||||
<span class="pin-icon" title="Pinned">📌</span>
|
||||
{% endif %}
|
||||
@@ -320,7 +320,7 @@
|
||||
<div class="note-tags">
|
||||
{% if note.tags %}
|
||||
{% for tag in note.get_tags_list() %}
|
||||
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -339,12 +339,12 @@
|
||||
</div>
|
||||
|
||||
<div class="note-actions">
|
||||
<a href="{{ url_for('view_note', slug=note.slug) }}" class="btn-action" title="View">
|
||||
<a href="{{ url_for('notes.view_note', slug=note.slug) }}" class="btn-action" title="View">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('view_note_mindmap', slug=note.slug) }}" class="btn-action" title="Mind Map">
|
||||
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn-action" title="Mind Map">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
<circle cx="3" cy="3" r="1.5"/>
|
||||
@@ -354,19 +354,19 @@
|
||||
<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">
|
||||
<a href="{{ url_for('notes_download.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">
|
||||
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn-action" title="Edit">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}" style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||
<button type="submit" class="btn-action btn-action-danger" title="Delete">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
@@ -384,7 +384,7 @@
|
||||
|
||||
{% else %}
|
||||
<div class="no-data">
|
||||
<p>No notes found. <a href="{{ url_for('create_note') }}">Create your first note</a>.</p>
|
||||
<p>No notes found. <a href="{{ url_for('notes.create_note') }}">Create your first note</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div> <!-- End notes-content -->
|
||||
|
||||
Reference in New Issue
Block a user