6 Commits

Author SHA1 Message Date
e6cc3e6a35 Remove .github directory
* deleted: .github/FUNDING.yml
2026-01-27 15:18:15 +01:00
64b8c3fccb Fix security issues. 2025-08-04 13:45:13 +02:00
f98e8f3e71 Allow uploading of files and folders. 2025-07-28 11:08:19 +02:00
87471e033e Require email for registration. 2025-07-22 07:27:02 +02:00
c03b27b960 Fix user menu in dark mode. 2025-07-22 07:27:02 +02:00
Jens Luedicke
9b9553ff8b Merge pull request #19 from nullmedium/nullmedium-patch-1
Delete .github/workflows directory
2025-07-21 16:27:07 +02:00
20 changed files with 3849 additions and 117 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
ko_fi: nullmedium

71
app.py
View File

@@ -438,6 +438,39 @@ def favicon_16():
def apple_touch_icon():
return send_from_directory(app.static_folder, 'apple-touch-icon.png', mimetype='image/png')
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
"""Serve uploaded files from the data directory"""
import os
from werkzeug.security import safe_join
# Ensure the request is from a logged-in user
if not g.user:
abort(403)
# Construct safe path to the uploaded file
upload_dir = '/data/uploads'
file_path = safe_join(upload_dir, filename)
if file_path is None or not os.path.exists(file_path):
abort(404)
# For notes, check if user has permission to view
if filename.startswith('notes/'):
# Extract the note from the database to check permissions
# This is a simplified check - in production you might want to store
# file ownership in a separate table for faster lookups
from models import Note
note = Note.query.filter_by(
file_path=filename.replace('notes/', ''),
company_id=g.user.company_id
).first()
if note and not note.can_user_view(g.user):
abort(403)
return send_from_directory(upload_dir, filename)
@app.route('/')
def home():
if g.user:
@@ -638,6 +671,8 @@ def register():
error = None
if not username:
error = 'Username is required'
elif not email:
error = 'Email is required'
elif not password:
error = 'Password is required'
elif password != confirm_password:
@@ -799,6 +834,8 @@ def register_freelancer():
error = None
if not username:
error = 'Username is required'
elif not email:
error = 'Email is required'
elif not password:
error = 'Password is required'
elif password != confirm_password:
@@ -1134,6 +1171,31 @@ def profile():
return render_template('profile.html', title='My Profile', user=user)
@app.route('/update-note-preferences', methods=['POST'])
@login_required
def update_note_preferences():
"""Update user's note preferences"""
user = User.query.get(session['user_id'])
# Get or create user preferences
if not user.preferences:
preferences = UserPreferences(user_id=user.id)
db.session.add(preferences)
else:
preferences = user.preferences
# Update font preference
note_preview_font = request.form.get('note_preview_font', 'system')
if note_preview_font in ['system', 'sans-serif', 'serif', 'monospace', 'georgia',
'palatino', 'garamond', 'bookman', 'comic-sans',
'trebuchet', 'arial-black', 'impact']:
preferences.note_preview_font = note_preview_font
db.session.commit()
flash('Note preferences updated successfully!', 'success')
return redirect(url_for('profile'))
@app.route('/update-avatar', methods=['POST'])
@login_required
def update_avatar():
@@ -1229,7 +1291,7 @@ def upload_avatar():
unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}"
# Create user avatar directory if it doesn't exist
avatar_dir = os.path.join(app.static_folder, 'uploads', 'avatars')
avatar_dir = os.path.join('/data', 'uploads', 'avatars')
os.makedirs(avatar_dir, exist_ok=True)
# Save the file
@@ -1237,8 +1299,9 @@ def upload_avatar():
file.save(file_path)
# Delete old avatar file if it exists and is a local upload
if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'):
old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/'))
if user.avatar_url and user.avatar_url.startswith('/uploads/avatars/'):
old_filename = user.avatar_url.split('/')[-1]
old_file_path = os.path.join(avatar_dir, old_filename)
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
@@ -1246,7 +1309,7 @@ def upload_avatar():
logger.warning(f"Failed to delete old avatar: {e}")
# Update user's avatar URL
user.avatar_url = f"/static/uploads/avatars/{unique_filename}"
user.avatar_url = f"/uploads/avatars/{unique_filename}"
db.session.commit()
flash('Avatar uploaded successfully!', 'success')

View File

@@ -0,0 +1,45 @@
"""Add file-based note support fields
Revision ID: 275ef106dc91
Revises: 85d490db548b
Create Date: 2025-07-18 15:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '275ef106dc91'
down_revision = '85d490db548b'
branch_labels = None
depends_on = None
def upgrade():
# Add file-based note support columns to note table
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_file_based', sa.Boolean(), nullable=True, default=False))
batch_op.add_column(sa.Column('file_path', sa.String(length=500), nullable=True))
batch_op.add_column(sa.Column('file_type', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('original_filename', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('file_size', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('mime_type', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('image_width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('image_height', sa.Integer(), nullable=True))
# Set default value for existing records
op.execute("UPDATE note SET is_file_based = FALSE WHERE is_file_based IS NULL")
def downgrade():
# Remove file-based note support columns from note table
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('image_height')
batch_op.drop_column('image_width')
batch_op.drop_column('mime_type')
batch_op.drop_column('file_size')
batch_op.drop_column('original_filename')
batch_op.drop_column('file_type')
batch_op.drop_column('file_path')
batch_op.drop_column('is_file_based')

View File

@@ -0,0 +1,31 @@
"""Add note preview font preference
Revision ID: 4a5b2c7d9e3f
Revises: 275ef106dc91
Create Date: 2024-07-18 13:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4a5b2c7d9e3f'
down_revision = '275ef106dc91'
branch_labels = None
depends_on = None
def upgrade():
# Add note_preview_font column to user_preferences table
with op.batch_alter_table('user_preferences', schema=None) as batch_op:
batch_op.add_column(sa.Column('note_preview_font', sa.String(length=50), nullable=True))
# Set default value for existing rows
op.execute("UPDATE user_preferences SET note_preview_font = 'system' WHERE note_preview_font IS NULL")
def downgrade():
# Remove note_preview_font column from user_preferences table
with op.batch_alter_table('user_preferences', schema=None) as batch_op:
batch_op.drop_column('note_preview_font')

View File

@@ -52,6 +52,18 @@ class Note(db.Model):
# Pin important notes
is_pinned = db.Column(db.Boolean, default=False)
# File-based note support
is_file_based = db.Column(db.Boolean, default=False) # True if note was created from uploaded file
file_path = db.Column(db.String(500), nullable=True) # Path to uploaded file
file_type = db.Column(db.String(20), nullable=True) # 'markdown', 'image', etc.
original_filename = db.Column(db.String(255), nullable=True) # Original uploaded filename
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
mime_type = db.Column(db.String(100), nullable=True) # MIME type of file
# For images
image_width = db.Column(db.Integer, nullable=True)
image_height = db.Column(db.Integer, nullable=True)
# Soft delete
is_archived = db.Column(db.Boolean, default=False)
archived_at = db.Column(db.DateTime, nullable=True)
@@ -162,12 +174,18 @@ class Note(db.Model):
return text
def render_html(self):
"""Render markdown content to HTML"""
"""Render markdown content to HTML with Wiki-style link support"""
try:
import markdown
from frontmatter_utils import parse_frontmatter
from wiki_links import process_wiki_links
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
# Process Wiki-style links before markdown rendering
body = process_wiki_links(body, current_note_id=self.id)
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html
@@ -263,6 +281,78 @@ class Note(db.Model):
return any(s.is_valid() for s in self.shares)
@property
def is_image(self):
"""Check if this is an image note"""
return self.file_type == 'image'
@property
def is_markdown_file(self):
"""Check if this is a markdown file note"""
return self.file_type == 'markdown'
@property
def is_pdf(self):
"""Check if this is a PDF note"""
return self.file_type == 'document' and self.original_filename and self.original_filename.lower().endswith('.pdf')
@property
def file_url(self):
"""Get the URL to access the uploaded file"""
if self.file_path and self.id:
from flask import url_for
return url_for('notes_api.serve_note_file', note_id=self.id)
return None
@property
def thumbnail_url(self):
"""Get thumbnail URL for image notes"""
if self.is_image and self.file_path:
# Could implement thumbnail generation later
return self.file_url
return None
@staticmethod
def allowed_file(filename):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {
'markdown': {'md', 'markdown', 'mdown', 'mkd'},
'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'},
'text': {'txt'},
'document': {'pdf', 'doc', 'docx'}
}
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
# Check all allowed extensions
for file_type, extensions in ALLOWED_EXTENSIONS.items():
if ext in extensions:
return True
return False
@staticmethod
def get_file_type_from_filename(filename):
"""Determine file type from extension"""
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
if ext in {'md', 'markdown', 'mdown', 'mkd'}:
return 'markdown'
elif ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}:
return 'image'
elif ext == 'txt':
return 'text'
elif ext in {'pdf', 'doc', 'docx'}:
return 'document'
else:
return 'other'
class NoteLink(db.Model):
"""Links between notes for creating relationships"""
id = db.Column(db.Integer, primary_key=True)

92
models/note_attachment.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Note attachment model for storing uploaded files associated with notes.
"""
from datetime import datetime
from . import db
class NoteAttachment(db.Model):
"""Model for files attached to notes (images, documents, etc.)"""
__tablename__ = 'note_attachment'
id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('note.id'), nullable=False)
# File information
filename = db.Column(db.String(255), nullable=False) # Stored filename
original_filename = db.Column(db.String(255), nullable=False) # Original upload name
file_path = db.Column(db.String(500), nullable=False) # Relative path from uploads dir
file_size = db.Column(db.Integer) # Size in bytes
mime_type = db.Column(db.String(100))
# File type for easier filtering
file_type = db.Column(db.String(20)) # 'image', 'document', 'archive', etc.
# Metadata
uploaded_at = db.Column(db.DateTime, default=datetime.now)
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# For images: dimensions
image_width = db.Column(db.Integer)
image_height = db.Column(db.Integer)
# Soft delete
is_deleted = db.Column(db.Boolean, default=False)
deleted_at = db.Column(db.DateTime)
# Relationships
note = db.relationship('Note', backref='attachments')
uploaded_by = db.relationship('User', backref='uploaded_attachments')
def __repr__(self):
return f'<NoteAttachment {self.original_filename} for Note {self.note_id}>'
@property
def is_image(self):
"""Check if attachment is an image"""
return self.file_type == 'image'
@property
def url(self):
"""Get the URL to access this attachment"""
return f'/uploads/notes/{self.file_path}'
def get_file_extension(self):
"""Get file extension"""
return self.original_filename.rsplit('.', 1)[1].lower() if '.' in self.original_filename else ''
@staticmethod
def allowed_file(filename, file_type='any'):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {
'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'},
'document': {'pdf', 'doc', 'docx', 'txt', 'md', 'csv', 'xls', 'xlsx'},
'archive': {'zip', 'tar', 'gz', '7z'},
'any': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'pdf', 'doc',
'docx', 'txt', 'md', 'csv', 'xls', 'xlsx', 'zip', 'tar', 'gz', '7z'}
}
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
allowed = ALLOWED_EXTENSIONS.get(file_type, ALLOWED_EXTENSIONS['any'])
return ext in allowed
@staticmethod
def get_file_type(filename):
"""Determine file type from extension"""
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
if ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}:
return 'image'
elif ext in {'pdf', 'doc', 'docx', 'txt', 'md', 'csv', 'xls', 'xlsx'}:
return 'document'
elif ext in {'zip', 'tar', 'gz', '7z'}:
return 'archive'
else:
return 'other'

View File

@@ -183,6 +183,9 @@ class UserPreferences(db.Model):
time_format = db.Column(db.String(10), default='24h')
time_format_24h = db.Column(db.Boolean, default=True) # True for 24h, False for 12h
# Note preview preferences
note_preview_font = db.Column(db.String(50), default='system') # system, serif, sans-serif, monospace, etc.
# Time tracking preferences
time_rounding_minutes = db.Column(db.Integer, default=0) # 0, 5, 10, 15, 30, 60
round_to_nearest = db.Column(db.Boolean, default=False) # False=round down, True=round to nearest

View File

@@ -17,3 +17,4 @@ Flask-Migrate==3.1.0
psycopg2-binary==2.9.9
markdown==3.4.4
PyYAML==6.0.1
Pillow==10.3.0

View File

@@ -8,8 +8,9 @@ from sqlalchemy import and_, or_
# Local application imports
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
Task, db)
Task, UserPreferences, db)
from routes.auth import company_required, login_required
from security_utils import sanitize_folder_path, validate_folder_access
# Create blueprint
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
@@ -30,6 +31,16 @@ def notes_list():
visibility_filter = request.args.get('visibility', '')
search_query = request.args.get('search', '')
# Sanitize folder filter if provided
if folder_filter:
try:
folder_filter = sanitize_folder_path(folder_filter)
# Validate folder exists
if not validate_folder_access(folder_filter, g.user.company_id, db.session):
folder_filter = '' # Reset to root if invalid
except ValueError:
folder_filter = '' # Reset to root if invalid
# Base query - only non-archived notes for the user's company
query = Note.query.filter_by(
company_id=g.user.company_id,
@@ -197,6 +208,41 @@ def create_note():
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Sanitize and validate folder if provided
if folder:
try:
folder = sanitize_folder_path(folder)
# Ensure folder exists or create it
if not validate_folder_access(folder, g.user.company_id, db.session):
# Create folder hierarchy if it doesn't exist
folder_parts = folder.split('/')
current_path = ''
for i, part in enumerate(folder_parts):
if i == 0:
current_path = part
else:
current_path = current_path + '/' + part
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=current_path
).first()
if not existing:
parent_path = '/'.join(folder_parts[:i]) if i > 0 else None
new_folder = NoteFolder(
name=part,
path=current_path,
parent_path=parent_path,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(new_folder)
db.session.flush() # Ensure folder is created before continuing
except ValueError as e:
flash(f'Invalid folder path: {str(e)}', 'error')
return redirect(url_for('notes.create_note'))
# Validate
if not title:
flash('Title is required', 'error')
@@ -366,6 +412,18 @@ def edit_note(slug):
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Sanitize and validate folder if provided
if folder:
try:
folder = sanitize_folder_path(folder)
# Validate folder exists
if not validate_folder_access(folder, g.user.company_id, db.session):
flash('Invalid folder selected', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
except ValueError as e:
flash(f'Invalid folder path: {str(e)}', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Validate
if not title:
flash('Title is required', 'error')
@@ -496,3 +554,29 @@ def notes_folders():
folder_tree=folder_tree,
folder_counts=folder_counts,
title='Manage Folders')
@notes_bp.route('/preferences', methods=['POST'])
@login_required
@company_required
def update_note_preferences():
"""Update note-related user preferences"""
note_preview_font = request.form.get('note_preview_font', 'system')
# Get or create user preferences
preferences = UserPreferences.query.filter_by(user_id=g.user.id).first()
if not preferences:
preferences = UserPreferences(user_id=g.user.id)
db.session.add(preferences)
# Update preferences
preferences.note_preview_font = note_preview_font
db.session.commit()
# Return JSON response for AJAX calls
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'font': note_preview_font})
# Otherwise redirect back
return redirect(request.referrer or url_for('notes.notes_list'))

View File

@@ -2,12 +2,15 @@
from datetime import datetime, timezone
# Third-party imports
from flask import Blueprint, abort, g, jsonify, request
from flask import Blueprint, abort, g, jsonify, request, url_for, current_app, send_file
from sqlalchemy import and_, or_
# Local application imports
from models import Note, NoteFolder, NoteLink, NoteVisibility, db
from routes.auth import company_required, login_required
from security_utils import (sanitize_folder_path, validate_folder_access,
generate_secure_file_path, validate_filename,
ensure_safe_file_path, get_safe_mime_type)
# Create blueprint
notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes')
@@ -20,6 +23,13 @@ def api_folder_details():
"""Get folder details including note count"""
folder_path = request.args.get('folder', '')
# Sanitize folder path if provided
if folder_path:
try:
folder_path = sanitize_folder_path(folder_path)
except ValueError:
return jsonify({'success': False, 'message': 'Invalid folder path'}), 400
# Get note count for this folder
note_count = Note.query.filter_by(
company_id=g.user.company_id,
@@ -62,15 +72,30 @@ def api_create_folder():
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
if '/' in folder_name or '..' in folder_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400
# Build full path
# Sanitize parent path if provided
if parent_path:
try:
parent_path = sanitize_folder_path(parent_path)
# Validate parent exists
if not validate_folder_access(parent_path, g.user.company_id, db.session):
return jsonify({'success': False, 'message': 'Parent folder does not exist'}), 404
except ValueError:
return jsonify({'success': False, 'message': 'Invalid parent folder path'}), 400
# Build full path and sanitize
if parent_path:
full_path = f"{parent_path}/{folder_name}"
else:
full_path = folder_name
try:
full_path = sanitize_folder_path(full_path)
except ValueError as e:
return jsonify({'success': False, 'message': str(e)}), 400
# Check if folder already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
@@ -118,9 +143,15 @@ def api_rename_folder():
if not old_path or not new_name:
return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400
# Sanitize old path
try:
old_path = sanitize_folder_path(old_path)
except ValueError:
return jsonify({'success': False, 'message': 'Invalid folder path'}), 400
# Validate new name
if '/' in new_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
if '/' in new_name or '..' in new_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400
# Find the folder
folder = NoteFolder.query.filter_by(
@@ -136,6 +167,12 @@ def api_rename_folder():
path_parts[-1] = new_name
new_path = '/'.join(path_parts)
# Sanitize new path
try:
new_path = sanitize_folder_path(new_path)
except ValueError as e:
return jsonify({'success': False, 'message': str(e)}), 400
# Check if new path already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
@@ -194,6 +231,12 @@ def api_delete_folder():
if not folder_path:
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
# Sanitize folder path
try:
folder_path = sanitize_folder_path(folder_path)
except ValueError:
return jsonify({'success': False, 'message': 'Invalid folder path'}), 400
# Check if folder has notes
note_count = Note.query.filter_by(
company_id=g.user.company_id,
@@ -558,3 +601,423 @@ def update_note_share(share_id):
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/autocomplete')
@login_required
@company_required
def autocomplete_notes():
"""Get notes for autocomplete suggestions"""
query = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 10)), 50) # Max 50 results
# Base query - only notes user can see
notes_query = Note.query.filter(
Note.company_id == g.user.company_id,
Note.is_archived == False
).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 query provided
if query:
search_pattern = f'%{query}%'
notes_query = notes_query.filter(
or_(
Note.title.ilike(search_pattern),
Note.slug.ilike(search_pattern)
)
)
# Order by relevance (exact matches first) and limit results
notes = notes_query.order_by(
# Exact title match first
(Note.title == query).desc(),
# Then exact slug match
(Note.slug == query).desc(),
# Then by title
Note.title
).limit(limit).all()
# Format results for autocomplete
results = []
for note in notes:
results.append({
'id': note.id,
'title': note.title,
'slug': note.slug,
'folder': note.folder or '',
'tags': note.get_tags_list(),
'visibility': note.visibility.value,
'preview': note.get_preview(100),
'updated_at': note.updated_at.isoformat() if note.updated_at else None
})
return jsonify({
'success': True,
'results': results,
'count': len(results),
'query': query
})
def clean_filename_for_title(filename):
"""Remove common folder-like prefixes from filename to create cleaner titles."""
# Remove file extension
name = filename.rsplit('.', 1)[0]
# Common prefixes that might be folder names
prefixes_to_remove = [
'Webpages_', 'Documents_', 'Files_', 'Downloads_',
'Images_', 'Photos_', 'Pictures_', 'Uploads_',
'Docs_', 'PDFs_', 'Attachments_', 'Archive_'
]
# Remove prefix if found at the beginning
for prefix in prefixes_to_remove:
if name.startswith(prefix):
name = name[len(prefix):]
break
# Also handle cases where the filename starts with numbers/dates
# e.g., "2024-01-15_Document_Name" -> "Document Name"
import re
name = re.sub(r'^\d{4}[-_]\d{2}[-_]\d{2}[-_]', '', name)
name = re.sub(r'^\d+[-_]', '', name)
# Replace underscores with spaces for readability
name = name.replace('_', ' ')
# Clean up multiple spaces
name = ' '.join(name.split())
return name if name else filename.rsplit('.', 1)[0]
@notes_api_bp.route('/upload', methods=['POST'])
@login_required
@company_required
def upload_note():
"""Upload a file (markdown or image) and create a note from it"""
import os
from PIL import Image
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'error': 'No file selected'}), 400
# Validate filename
try:
original_filename = validate_filename(file.filename)
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
# Check if file type is allowed
if not Note.allowed_file(original_filename):
return jsonify({'success': False, 'error': 'File type not allowed. Supported: markdown (.md), images (.jpg, .png, .gif, .webp, .svg), text (.txt), documents (.pdf, .doc, .docx)'}), 400
# Get file info
file_type = Note.get_file_type_from_filename(original_filename)
# Generate secure file path using UUID
try:
relative_path = generate_secure_file_path(file_type, original_filename)
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
# Create upload directory in persistent volume
upload_base = '/data/uploads/notes'
file_path = os.path.join(upload_base, relative_path)
upload_dir = os.path.dirname(file_path)
# Ensure directory exists
os.makedirs(upload_dir, exist_ok=True)
# Ensure the path is safe
try:
file_path = ensure_safe_file_path(upload_base, relative_path)
except ValueError:
return jsonify({'success': False, 'error': 'Invalid file path'}), 400
# Save the file
file.save(file_path)
# Get file size
file_size = os.path.getsize(file_path)
# Get safe MIME type
mime_type = get_safe_mime_type(original_filename)
# Create note content based on file type
if file_type == 'markdown' or file_type == 'text':
# Read text content
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except UnicodeDecodeError:
# If not UTF-8, try with different encoding
with open(file_path, 'r', encoding='latin-1') as f:
content = f.read()
title = request.form.get('title') or clean_filename_for_title(original_filename)
image_width = image_height = None
elif file_type == 'image':
# For images, create a simple markdown content with the image
title = request.form.get('title') or clean_filename_for_title(original_filename)
# Content will be updated after note is created with its ID
content = f"![{title}](PLACEHOLDER)"
# Get image dimensions
try:
with Image.open(file_path) as img:
image_width, image_height = img.size
except:
image_width = image_height = None
else:
# For other documents
title = request.form.get('title') or clean_filename_for_title(original_filename)
# Content will be updated after note is created with its ID
content = f"[Download {original_filename}](PLACEHOLDER)"
image_width = image_height = None
# Create the note
note = Note(
title=title,
content=content,
visibility=NoteVisibility.PRIVATE,
created_by_id=g.user.id,
company_id=g.user.company_id,
is_file_based=True,
file_path=relative_path,
file_type=file_type,
original_filename=original_filename,
file_size=file_size,
mime_type=mime_type
)
if file_type == 'image' and image_width:
note.image_width = image_width
note.image_height = image_height
# Set folder if provided
folder = request.form.get('folder')
if folder:
try:
folder = sanitize_folder_path(folder)
# Validate folder exists
if not validate_folder_access(folder, g.user.company_id, db.session):
# Create folder if it doesn't exist
folder_parts = folder.split('/')
current_path = ''
for i, part in enumerate(folder_parts):
if i == 0:
current_path = part
else:
current_path = current_path + '/' + part
existing_folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=current_path
).first()
if not existing_folder:
parent_path = '/'.join(folder_parts[:i]) if i > 0 else None
new_folder = NoteFolder(
name=part,
path=current_path,
parent_path=parent_path,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(new_folder)
note.folder = folder
except ValueError:
# Skip invalid folder
pass
# Set tags if provided
tags = request.form.get('tags')
if tags:
note.tags = tags
# Generate slug
note.generate_slug()
try:
db.session.add(note)
db.session.commit()
# Update content with proper file URLs now that we have the note ID
if file_type == 'image':
note.content = f"![{note.title}]({url_for('notes_api.serve_note_file', note_id=note.id)})"
elif file_type not in ['markdown', 'text']:
note.content = f"[Download {original_filename}]({url_for('notes_api.serve_note_file', note_id=note.id)})"
db.session.commit()
return jsonify({
'success': True,
'note': {
'id': note.id,
'title': note.title,
'slug': note.slug,
'file_type': note.file_type,
'file_url': url_for('notes_api.serve_note_file', note_id=note.id),
'url': url_for('notes.view_note', slug=note.slug)
}
})
except Exception as e:
db.session.rollback()
# Delete uploaded file on error
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/bulk-move', methods=['POST'])
@login_required
@company_required
def bulk_move_notes():
"""Move multiple notes to a different folder."""
data = request.get_json()
if not data or 'note_ids' not in data:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
note_ids = data.get('note_ids', [])
folder = data.get('folder', '')
if not note_ids:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
try:
# Get all notes and verify ownership
notes = Note.query.filter(
Note.id.in_(note_ids),
Note.company_id == g.user.company_id
).all()
if len(notes) != len(note_ids):
return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403
# Update folder for all notes
for note in notes:
note.folder = folder if folder else None
db.session.commit()
return jsonify({
'success': True,
'message': f'Moved {len(notes)} note(s) to {"folder: " + folder if folder else "root"}'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/bulk-delete', methods=['POST'])
@login_required
@company_required
def bulk_delete_notes():
"""Delete multiple notes."""
data = request.get_json()
if not data or 'note_ids' not in data:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
note_ids = data.get('note_ids', [])
if not note_ids:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
try:
# Get all notes and verify ownership
notes = Note.query.filter(
Note.id.in_(note_ids),
Note.company_id == g.user.company_id
).all()
if len(notes) != len(note_ids):
return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403
# Delete files if they exist
import os
for note in notes:
if note.file_path:
try:
# Use the safe base path
upload_base = '/data/uploads/notes'
file_path = ensure_safe_file_path(upload_base, note.file_path)
if os.path.exists(file_path):
os.remove(file_path)
except:
pass # Continue even if file deletion fails
# Delete all notes
for note in notes:
db.session.delete(note)
db.session.commit()
return jsonify({
'success': True,
'message': f'Deleted {len(notes)} note(s)'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/file/<int:note_id>')
@login_required
@company_required
def serve_note_file(note_id):
"""Securely serve uploaded files after validating access permissions"""
import os
# Get the note and validate access
note = Note.query.filter_by(
id=note_id,
company_id=g.user.company_id
).first_or_404()
# Check if user can view the note
if not note.can_user_view(g.user):
abort(403)
# Check if note has a file
if not note.file_path:
abort(404)
# Build safe file path
upload_base = '/data/uploads/notes'
try:
safe_path = ensure_safe_file_path(upload_base, note.file_path)
except ValueError:
abort(403)
# Check if file exists
if not os.path.exists(safe_path):
abort(404)
# Get safe MIME type
mime_type = get_safe_mime_type(note.original_filename or 'file')
# Send the file
return send_file(
safe_path,
mimetype=mime_type,
as_attachment=False, # Display inline for images
download_name=note.original_filename
)

View File

@@ -14,6 +14,7 @@ from flask import (Blueprint, Response, abort, flash, g, redirect, request,
from frontmatter_utils import parse_frontmatter
from models import Note, db
from routes.auth import company_required, login_required
from security_utils import sanitize_folder_path, validate_folder_access
# Create blueprint
notes_download_bp = Blueprint('notes_download', __name__)
@@ -30,8 +31,11 @@ def download_note(slug, format):
if not note.can_user_view(g.user):
abort(403)
# Prepare filename
# Prepare filename - extra sanitization
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
# Ensure filename isn't too long
if len(safe_filename) > 100:
safe_filename = safe_filename[:100]
timestamp = datetime.now().strftime('%Y%m%d')
if format == 'md':
@@ -142,6 +146,8 @@ def download_notes_bulk():
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 len(safe_filename) > 100:
safe_filename = safe_filename[:100]
if format == 'md':
content = note.content
@@ -195,8 +201,15 @@ def download_notes_bulk():
@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)
# Decode and sanitize folder path
try:
folder_path = sanitize_folder_path(unquote(folder_path))
except ValueError:
abort(400, "Invalid folder path")
# Validate folder exists and user has access
if not validate_folder_access(folder_path, g.user.company_id, db.session):
abort(404, "Folder not found")
# Get all notes in this folder
notes = Note.query.filter_by(
@@ -220,6 +233,8 @@ def download_folder(folder_path, format):
for note in viewable_notes:
# Get content based on format
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
if len(safe_filename) > 100:
safe_filename = safe_filename[:100]
if format == 'md':
content = note.content

260
security_utils.py Normal file
View File

@@ -0,0 +1,260 @@
"""
Security utilities for path sanitization and file handling.
"""
import os
import re
import uuid
from werkzeug.utils import secure_filename
def sanitize_folder_path(path):
"""
Sanitize folder path to prevent traversal attacks.
Args:
path: The folder path to sanitize
Returns:
Sanitized path string
Raises:
ValueError: If path contains forbidden patterns or characters
"""
if not path:
return ""
# Remove any leading/trailing slashes and whitespace
path = path.strip().strip('/')
# Reject paths containing dangerous patterns
dangerous_patterns = [
'..', # Parent directory traversal
'./', # Current directory reference
'\\', # Windows path separator
'\0', # Null byte
'~', # Home directory reference
'\x00', # Alternative null byte
'%2e%2e', # URL encoded ..
'%252e%252e', # Double URL encoded ..
]
# Check both original and lowercase version
path_lower = path.lower()
for pattern in dangerous_patterns:
if pattern in path or pattern in path_lower:
raise ValueError(f"Invalid path: contains forbidden pattern '{pattern}'")
# Only allow alphanumeric, spaces, hyphens, underscores, and forward slashes
if not re.match(r'^[a-zA-Z0-9\s\-_/]+$', path):
raise ValueError("Invalid path: contains forbidden characters")
# Normalize path (remove double slashes, etc.)
path_parts = [p for p in path.split('/') if p]
# Additional check: ensure no part is '..' or '.' or empty
for part in path_parts:
if part in ('.', '..', '') or part.strip() == '':
raise ValueError("Invalid path: contains directory traversal")
# Check each part doesn't exceed reasonable length
if len(part) > 100:
raise ValueError("Invalid path: folder name too long")
# Check total depth
if len(path_parts) > 10:
raise ValueError("Invalid path: folder depth exceeds maximum allowed")
normalized = '/'.join(path_parts)
# Final length check
if len(normalized) > 500:
raise ValueError("Invalid path: total path length exceeds maximum allowed")
return normalized
def generate_secure_file_path(file_type, original_filename):
"""
Generate secure file path using UUID to prevent predictable paths.
Args:
file_type: Type of file (image, markdown, text, document)
original_filename: Original uploaded filename
Returns:
Secure relative path for file storage
Raises:
ValueError: If file type is not allowed
"""
if not original_filename:
raise ValueError("Filename is required")
# Extract and validate extension
_, ext = os.path.splitext(original_filename)
ext = ext.lower()
# Whitelist allowed extensions by type
allowed_extensions = {
'markdown': {'.md', '.markdown', '.mdown', '.mkd'},
'image': {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'},
'text': {'.txt'},
'document': {'.pdf', '.doc', '.docx'}
}
# Verify file type matches extension
type_extensions = allowed_extensions.get(file_type, set())
if ext not in type_extensions:
all_allowed = set()
for exts in allowed_extensions.values():
all_allowed.update(exts)
if ext not in all_allowed:
raise ValueError(f"File extension '{ext}' is not allowed")
# Find correct file type based on extension
for ftype, exts in allowed_extensions.items():
if ext in exts:
file_type = ftype
break
# Generate UUID for filename
file_id = str(uuid.uuid4())
# Create secure filename
secure_name = f"{file_id}{ext}"
# Return path with type subdirectory
return f"{file_type}/{secure_name}"
def validate_folder_access(folder_path, company_id, db_session):
"""
Validate folder exists and belongs to company.
Args:
folder_path: Path to validate
company_id: Company ID to check against
db_session: Database session
Returns:
True if folder is valid and accessible, False otherwise
"""
if not folder_path:
return True # Root folder is always valid
try:
# Sanitize the path first
folder_path = sanitize_folder_path(folder_path)
except ValueError:
return False
# Import here to avoid circular imports
from models import NoteFolder
# Check if folder exists in database
folder = db_session.query(NoteFolder).filter_by(
path=folder_path,
company_id=company_id
).first()
return folder is not None
def ensure_safe_file_path(base_path, file_path):
"""
Ensure a file path is within the safe base directory.
Args:
base_path: The safe base directory
file_path: The file path to check
Returns:
Absolute safe path
Raises:
ValueError: If path would escape the base directory
"""
# Get absolute paths
base_abs = os.path.abspath(base_path)
# Join paths and resolve
full_path = os.path.join(base_abs, file_path)
full_abs = os.path.abspath(full_path)
# Ensure the resolved path is within the base
if not full_abs.startswith(base_abs + os.sep) and full_abs != base_abs:
raise ValueError("Path traversal detected")
return full_abs
def validate_filename(filename):
"""
Validate and secure a filename.
Args:
filename: The filename to validate
Returns:
Secure filename
Raises:
ValueError: If filename is invalid
"""
if not filename:
raise ValueError("Filename is required")
# Use werkzeug's secure_filename
secured = secure_filename(filename)
if not secured or secured == '':
raise ValueError("Invalid filename")
# Additional checks
if len(secured) > 255:
raise ValueError("Filename too long")
# Ensure it has an extension
if '.' not in secured:
raise ValueError("Filename must have an extension")
return secured
def get_safe_mime_type(filename):
"""
Get MIME type for a filename, defaulting to safe types.
Args:
filename: The filename to check
Returns:
Safe MIME type string
"""
ext = os.path.splitext(filename)[1].lower()
mime_types = {
# Markdown
'.md': 'text/markdown',
'.markdown': 'text/markdown',
'.mdown': 'text/markdown',
'.mkd': 'text/markdown',
# Images
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
# Text
'.txt': 'text/plain',
# Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}
return mime_types.get(ext, 'application/octet-stream')

View File

@@ -656,6 +656,7 @@
background-color: var(--dropdown-bg) !important;
border: 1px solid var(--border-primary);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
z-index: 1000 !important; /* Ensure dropdown is above other elements */
}
[data-theme="dark"] .user-dropdown-header {
@@ -685,6 +686,13 @@
border-top: 1px solid var(--border-primary);
}
/* Fix dropdown toggle interaction in dark mode */
[data-theme="dark"] .user-dropdown-toggle {
position: relative;
z-index: 10;
cursor: pointer;
}
/* Override mobile-specific white background */
@media (max-width: 1024px) {
[data-theme="dark"] .sidebar.active .user-dropdown-modal.active,

View File

@@ -19,6 +19,10 @@
</p>
</div>
<div class="header-actions">
<button type="button" class="btn btn-secondary" id="help-toggle">
<span class="icon"><i class="ti ti-help"></i></span>
Help
</button>
<button type="button" class="btn btn-secondary" id="settings-toggle">
<span class="icon"><i class="ti ti-settings"></i></span>
Settings
@@ -132,6 +136,59 @@
</div>
</div>
<!-- Help Panel -->
<div class="help-panel" id="help-panel" style="display: none;">
<div class="help-card">
<h3 class="help-title">
<span class="icon"><i class="ti ti-help"></i></span>
Markdown & Wiki Syntax Help
</h3>
<div class="help-content">
<div class="help-section">
<h4>Wiki-style Links</h4>
<ul class="help-list">
<li><code>[[Note Title]]</code> - Create a link to another note</li>
<li><code>![[Note Title]]</code> - Embed another note's content</li>
</ul>
<p class="help-note">Wiki links work with note titles or slugs. If a note isn't found, it will show as a broken link.</p>
</div>
<div class="help-section">
<h4>Basic Markdown</h4>
<ul class="help-list">
<li><code># Heading 1</code> / <code>## Heading 2</code> / <code>### Heading 3</code></li>
<li><code>**bold**</code> / <code>*italic*</code> / <code>`code`</code></li>
<li><code>[Link text](url)</code> - Regular links</li>
<li><code>![Alt text](image-url)</code> - Images</li>
<li><code>- Item</code> - Bullet lists</li>
<li><code>1. Item</code> - Numbered lists</li>
<li><code>- [ ] Task</code> - Checklists</li>
<li><code>> Quote</code> - Blockquotes</li>
</ul>
</div>
<div class="help-section">
<h4>Advanced Markdown</h4>
<ul class="help-list">
<li><code>```language<br>code block<br>```</code> - Code blocks with syntax highlighting</li>
<li><code>| Header | Header |<br>|--------|--------|<br>| Cell | Cell |</code> - Tables</li>
<li><code>---</code> - Horizontal rule</li>
</ul>
</div>
<div class="help-section">
<h4>Tips</h4>
<ul class="help-list">
<li>Use frontmatter (YAML at the top) to set metadata</li>
<li>Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save quickly</li>
<li>The preview updates in real-time as you type</li>
<li>Wiki embeds show a preview of the linked note</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Editor Form -->
<form method="POST" id="note-form">
<input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required>
@@ -222,12 +279,33 @@
<i class="ti ti-minus"></i>
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn" onclick="insertMarkdown('[[', ']]')" title="Wiki Link - Link to another note">
<i class="ti ti-file-symlink"></i>
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('![[', ']]')" title="Wiki Embed - Embed another note">
<i class="ti ti-file-import"></i>
</button>
</div>
</div>
<!-- Ace Editor Container -->
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '# New Note\n\nStart writing here...' }}</div>
<textarea id="content" name="content" style="display: none;" required>{{ note.content if note else '# New Note\n\nStart writing here...' }}</textarea>
<!-- Wiki Autocomplete Popup -->
<div id="wiki-autocomplete" class="wiki-autocomplete" style="display: none;">
<div class="autocomplete-search">
<input type="text" id="autocomplete-query" class="autocomplete-input" placeholder="Search notes...">
</div>
<div id="autocomplete-results" class="autocomplete-results">
<!-- Results will be populated here -->
</div>
</div>
<!-- Editor Footer -->
<div class="editor-footer">
<div class="editor-stats">
@@ -601,6 +679,196 @@
margin-top: 2rem;
}
/* Help Panel */
.help-panel {
margin-bottom: 2rem;
animation: slideDown 0.3s ease-out;
}
.help-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.help-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.help-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.help-section {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
}
.help-section h4 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1rem;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
}
.help-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
}
.help-list li:last-child {
border-bottom: none;
}
.help-list code {
background: #fff;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.875rem;
color: #e83e8c;
border: 1px solid #dee2e6;
}
.help-note {
margin: 1rem 0 0 0;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
}
kbd {
background-color: #f7f7f7;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
display: inline-block;
font-size: 0.85em;
font-family: monospace;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* Wiki Autocomplete */
.wiki-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 300px;
max-height: 250px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.autocomplete-search {
padding: 0.375rem;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.autocomplete-input {
width: 100%;
padding: 0.375rem 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.8125rem;
outline: none;
}
.autocomplete-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.15rem rgba(0, 123, 255, 0.25);
}
.autocomplete-results {
flex: 1;
overflow-y: auto;
max-height: 180px;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s ease;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: #f8f9fa;
}
.autocomplete-item.selected {
background: #e7f3ff;
}
.autocomplete-item-title {
font-weight: 500;
color: #333;
font-size: 0.875rem;
margin-bottom: 0.125rem;
}
.autocomplete-item-meta {
display: flex;
gap: 0.75rem;
font-size: 0.6875rem;
color: #6c757d;
}
.autocomplete-item-folder,
.autocomplete-item-visibility {
display: flex;
align-items: center;
gap: 0.125rem;
}
.autocomplete-item-folder i,
.autocomplete-item-visibility i {
font-size: 0.75rem;
}
.autocomplete-item-preview {
display: none; /* Hide preview to save space */
}
.autocomplete-empty {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
.autocomplete-loading {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
/* Animations */
@keyframes slideDown {
from {
@@ -952,7 +1220,8 @@ function initializeAceEditor() {
useSoftTabs: true,
wrap: true,
showInvisibles: false,
scrollPastEnd: 0.5
scrollPastEnd: 0.5,
behavioursEnabled: true // Keep auto-closing brackets enabled
});
// Set initial content from hidden textarea
@@ -1057,17 +1326,37 @@ document.addEventListener('DOMContentLoaded', function() {
// Settings toggle
const settingsBtn = document.getElementById('settings-toggle');
const settingsPanel = document.getElementById('settings-panel');
const helpBtn = document.getElementById('help-toggle');
const helpPanel = document.getElementById('help-panel');
settingsBtn.addEventListener('click', function() {
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
settingsPanel.style.display = 'block';
this.classList.add('active');
// Close help panel if open
helpPanel.style.display = 'none';
helpBtn.classList.remove('active');
} else {
settingsPanel.style.display = 'none';
this.classList.remove('active');
}
});
// Help toggle
helpBtn.addEventListener('click', function() {
if (helpPanel.style.display === 'none' || !helpPanel.style.display) {
helpPanel.style.display = 'block';
this.classList.add('active');
// Close settings panel if open
settingsPanel.style.display = 'none';
settingsBtn.classList.remove('active');
} else {
helpPanel.style.display = 'none';
this.classList.remove('active');
}
});
// Preview toggle
const previewToggle = document.getElementById('preview-toggle');
const previewPanel = document.getElementById('preview-panel');
@@ -1130,6 +1419,285 @@ document.addEventListener('DOMContentLoaded', function() {
syncContentAndUpdatePreview(false);
syncSettingsToHiddenFields(); // Sync to hidden fields
});
// Wiki Autocomplete functionality
let autocompleteActive = false;
let autocompleteType = ''; // 'link' or 'embed'
let autocompleteStartPos = null;
let selectedIndex = -1;
let autocompleteResults = [];
const autocompletePopup = document.getElementById('wiki-autocomplete');
const autocompleteInput = document.getElementById('autocomplete-query');
const autocompleteResultsDiv = document.getElementById('autocomplete-results');
// Listen for [[ or ![[ triggers in ACE editor
aceEditor.commands.on("afterExec", function(e) {
if (e.command.name === "insertstring") {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
// Check for [[ or ![[ triggers
if (textBeforeCursor.endsWith('[[')) {
// Regular link
autocompleteType = 'link';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
} else if (textBeforeCursor.endsWith('![[')) {
// Embed
autocompleteType = 'embed';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
}
}
});
function showAutocomplete() {
autocompleteActive = true;
selectedIndex = -1;
// Get cursor position in the editor
const cursorPixelPos = aceEditor.renderer.textToScreenCoordinates(
autocompleteStartPos.row,
autocompleteStartPos.column
);
// Get editor container position
const editorContainer = aceEditor.container;
const editorRect = editorContainer.getBoundingClientRect();
// Calculate position relative to the editor container
// The popup should appear right below the cursor
const lineHeight = aceEditor.renderer.lineHeight;
const left = cursorPixelPos.pageX - editorRect.left - window.scrollX;
const top = cursorPixelPos.pageY - editorRect.top + lineHeight + 5 - window.scrollY;
// Make sure popup doesn't go off the right edge
const maxLeft = editorRect.width - 300; // 300px is popup width
autocompletePopup.style.left = Math.min(left, maxLeft) + 'px';
autocompletePopup.style.top = top + 'px';
autocompletePopup.style.display = 'block';
// Focus input and load initial results
autocompleteInput.value = '';
autocompleteInput.focus();
searchNotes('');
}
function hideAutocomplete() {
autocompleteActive = false;
autocompletePopup.style.display = 'none';
aceEditor.focus();
}
function searchNotes(query) {
// Show loading state
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-loading">Loading...</div>';
// Fetch notes from API
fetch(`/api/notes/autocomplete?q=${encodeURIComponent(query)}&limit=20`)
.then(response => response.json())
.then(data => {
if (data.success) {
autocompleteResults = data.results;
renderResults(data.results);
}
})
.catch(error => {
console.error('Error fetching notes:', error);
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">Error loading notes</div>';
});
}
function renderResults(results) {
if (results.length === 0) {
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">No notes found</div>';
return;
}
let html = '';
results.forEach((note, index) => {
const visibilityIcon = note.visibility === 'Private' ? 'lock' :
note.visibility === 'Team' ? 'users' : 'building';
html += `
<div class="autocomplete-item ${index === selectedIndex ? 'selected' : ''}"
data-index="${index}">
<div class="autocomplete-item-title">${escapeHtml(note.title)}</div>
<div class="autocomplete-item-meta">
${note.folder ? `
<div class="autocomplete-item-folder">
<i class="ti ti-folder"></i>
${escapeHtml(note.folder)}
</div>
` : ''}
<div class="autocomplete-item-visibility">
<i class="ti ti-${visibilityIcon}"></i>
${note.visibility}
</div>
</div>
${note.preview ? `
<div class="autocomplete-item-preview">${escapeHtml(note.preview)}</div>
` : ''}
</div>
`;
});
autocompleteResultsDiv.innerHTML = html;
// Add click handlers
document.querySelectorAll('.autocomplete-item').forEach(item => {
item.addEventListener('click', function() {
const index = parseInt(this.dataset.index);
selectNote(autocompleteResults[index]);
});
});
}
function selectNote(note) {
if (!note) return;
// Get current cursor position
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if there are already closing brackets after cursor
const textAfterCursor = line.substring(cursor.column);
const hasClosingBrackets = textAfterCursor.startsWith(']]');
// Insert only the title if closing brackets already exist
const insertText = hasClosingBrackets ? note.title : note.title + ']]';
// Insert the text
aceEditor.session.insert(cursor, insertText);
// If we didn't add closing brackets, move cursor past existing ones
if (hasClosingBrackets) {
const newPos = {
row: cursor.row,
column: cursor.column + note.title.length + 2 // +2 for ]]
};
aceEditor.moveCursorToPosition(newPos);
}
// Hide autocomplete
hideAutocomplete();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle input changes
let searchTimeout;
autocompleteInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchNotes(e.target.value);
}, 200);
});
// Handle keyboard navigation
autocompleteInput.addEventListener('keydown', function(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < autocompleteResults.length - 1) {
selectedIndex++;
updateSelection();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) {
selectedIndex--;
updateSelection();
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < autocompleteResults.length) {
selectNote(autocompleteResults[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideAutocomplete();
}
});
function updateSelection() {
document.querySelectorAll('.autocomplete-item').forEach((item, index) => {
if (index === selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
// Close autocomplete when clicking outside
document.addEventListener('click', function(e) {
if (autocompleteActive && !autocompletePopup.contains(e.target)) {
hideAutocomplete();
}
});
// Handle closing brackets and escape
aceEditor.on('change', function(e) {
if (!autocompleteActive) return;
if (e.action === 'insert') {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if user typed ]] to close the link
if (e.lines[0].includes(']')) {
const textBeforeCursor = line.substring(0, cursor.column);
if (textBeforeCursor.endsWith(']]')) {
hideAutocomplete();
}
}
} else if (e.action === 'remove') {
// Check if user deleted the opening [[
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
if (!textBeforeCursor.includes('[[') && !textBeforeCursor.includes('![[')) {
hideAutocomplete();
}
}
});
// Handle escape key in editor
aceEditor.commands.addCommand({
name: 'closeAutocomplete',
bindKey: {win: 'Escape', mac: 'Escape'},
exec: function() {
if (autocompleteActive) {
hideAutocomplete();
return true;
}
return false;
}
});
});
</script>

View File

@@ -2,99 +2,156 @@
{% block content %}
<div class="page-container">
<!-- Page Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">{{ note.title }}</h1>
<div class="page-meta">
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
{% if note.visibility.value == 'Private' %}<i class="ti ti-lock"></i>{% elif note.visibility.value == 'Team' %}<i class="ti ti-users"></i>{% else %}<i class="ti ti-building"></i>{% endif %}
{{ note.visibility.value }}
</span>
{% if note.is_pinned %}
<span class="pin-badge">
<span class="icon"><i class="ti ti-pin"></i></span>
Pinned
</span>
{% endif %}
<span class="meta-divider"></span>
<span class="author">
<span class="icon"><i class="ti ti-user"></i></span>
{{ note.created_by.username }}
</span>
<span class="meta-divider"></span>
<span class="date">
<span class="icon"><i class="ti ti-calendar"></i></span>
Created {{ note.created_at|format_date }}
</span>
{% if note.updated_at > note.created_at %}
<span class="meta-divider"></span>
<span class="date">
<span class="icon"><i class="ti ti-refresh"></i></span>
Updated {{ note.updated_at|format_date }}
</span>
{% endif %}
{% if note.folder %}
<span class="meta-divider"></span>
<span class="folder">
<span class="icon"><i class="ti ti-folder"></i></span>
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="folder-link">
{{ note.folder }}
</a>
</span>
{% endif %}
</div>
</div>
<!-- Compact Unified Header -->
<div class="note-header-compact">
<!-- Title Bar -->
<div class="header-title-bar">
<button class="btn-icon" onclick="window.location.href='{{ url_for('notes.notes_list') }}'">
<i class="ti ti-arrow-left"></i>
</button>
<h1 class="note-title">{{ note.title }}</h1>
<div class="header-actions">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="downloadDropdown" data-toggle="dropdown">
<span class="icon"><i class="ti ti-download"></i></span>
Download
<!-- Context-Specific Primary Actions -->
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
<!-- PDF Actions -->
<div class="zoom-controls">
<button class="btn-icon" onclick="pdfZoomOut()" title="Zoom Out">
<i class="ti ti-zoom-out"></i>
</button>
<span class="zoom-level" id="zoom-level">100%</span>
<button class="btn-icon" onclick="pdfZoomIn()" title="Zoom In">
<i class="ti ti-zoom-in"></i>
</button>
</div>
<a href="{{ note.file_url }}" class="btn btn-primary btn-sm" download>
<i class="ti ti-download"></i>
<span class="btn-text">Download PDF</span>
</a>
{% elif note.is_image %}
<!-- Image Actions -->
<button class="btn-icon" onclick="toggleFullscreen()" title="Fullscreen">
<i class="ti ti-maximize"></i>
</button>
<div class="dropdown-menu">
<a href="{{ note.file_url }}" class="btn btn-primary btn-sm" download>
<i class="ti ti-download"></i>
<span class="btn-text">Download</span>
</a>
{% else %}
<!-- Markdown/Text Actions -->
{% if note.can_user_edit(g.user) %}
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary btn-sm">
<i class="ti ti-pencil"></i>
<span class="btn-text">Edit</span>
</a>
{% endif %}
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary btn-sm">
<i class="ti ti-brain"></i>
<span class="btn-text">Mind Map</span>
</a>
{% endif %}
<!-- Common Actions -->
{% if note.can_user_edit(g.user) %}
<button class="btn btn-secondary btn-sm" onclick="showShareModal()">
<i class="ti ti-share"></i>
<span class="btn-text">Share</span>
</button>
{% endif %}
<!-- More Actions Dropdown -->
<div class="dropdown">
<button class="btn-icon" data-toggle="dropdown" title="More actions">
<i class="ti ti-dots-vertical"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
{% if not (note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf')) %}
<!-- Download options for non-PDF -->
<h6 class="dropdown-header">Download as</h6>
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='md') }}">
<span class="icon"><i class="ti ti-file-text"></i></span>
Markdown (.md)
<i class="ti ti-file-text"></i> Markdown
</a>
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='html') }}">
<span class="icon"><i class="ti ti-world"></i></span>
HTML (.html)
<i class="ti ti-world"></i> HTML
</a>
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='txt') }}">
<span class="icon"><i class="ti ti-file"></i></span>
Plain Text (.txt)
<i class="ti ti-file"></i> Plain Text
</a>
<div class="dropdown-divider"></div>
{% endif %}
{% if note.is_pinned %}
<a class="dropdown-item" href="#">
<i class="ti ti-pin-filled"></i> Pinned
</a>
{% else %}
<a class="dropdown-item" href="#">
<i class="ti ti-pin"></i> Pin Note
</a>
{% endif %}
<a class="dropdown-item" onclick="window.print()">
<i class="ti ti-printer"></i> Print
</a>
{% if note.can_user_edit(g.user) %}
<div class="dropdown-divider"></div>
<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="dropdown-item text-danger">
<i class="ti ti-trash"></i> Delete Note
</button>
</form>
{% endif %}
</div>
</div>
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary">
<span class="icon"><i class="ti ti-brain"></i></span>
Mind Map
</a>
{% if note.can_user_edit(g.user) %}
<button type="button" class="btn btn-secondary" onclick="showShareModal()">
<span class="icon"><i class="ti ti-link"></i></span>
Share
</button>
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary">
<span class="icon"><i class="ti ti-pencil"></i></span>
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">
<span class="icon"><i class="ti ti-trash"></i></span>
Delete
</button>
</form>
{% endif %}
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">
<span class="icon"><i class="ti ti-arrow-left"></i></span>
Back to Notes
</a>
</div>
</div>
<!-- Metadata Bar -->
<div class="header-meta-bar">
{% if note.folder %}
<span class="meta-item">
<i class="ti ti-folder"></i>
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}">{{ note.folder }}</a>
</span>
{% endif %}
{% if note.tags %}
<span class="meta-item">
<i class="ti ti-tag"></i>
{% for tag in note.get_tags_list() %}
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-link">{{ tag }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</span>
{% endif %}
<span class="meta-item">
<i class="ti ti-user"></i> {{ note.created_by.username }}
</span>
<span class="meta-item">
<i class="ti ti-clock"></i>
{% if note.updated_at > note.created_at %}
Updated {{ note.updated_at|format_date }}
{% else %}
Created {{ note.created_at|format_date }}
{% endif %}
</span>
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
{% if note.visibility.value == 'Private' %}
<i class="ti ti-lock"></i>
{% elif note.visibility.value == 'Team' %}
<i class="ti ti-users"></i>
{% else %}
<i class="ti ti-building"></i>
{% endif %}
{{ note.visibility.value }}
</span>
</div>
</div>
<!-- Note Metadata Card -->
@@ -152,9 +209,22 @@
<!-- Note Content -->
<div class="content-card">
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
<!-- PDF Preview (toolbar moved to unified header) -->
<div class="pdf-preview-container">
<iframe id="pdf-viewer" src="{{ note.file_url }}" class="pdf-viewer"></iframe>
</div>
{% elif note.is_image %}
<!-- Image Preview -->
<div class="image-preview-container">
<img src="{{ note.file_url }}" alt="{{ note.title }}" class="note-image" id="note-image">
</div>
{% else %}
<!-- Regular Content -->
<div class="markdown-content">
{{ note.render_html()|safe }}
</div>
{% endif %}
</div>
<!-- Linked Notes Section -->
@@ -289,14 +359,197 @@
margin: 0 auto;
}
/* Page Header - Time Tracking style */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
/* Compact Unified Header */
.note-header-compact {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 1.5rem;
position: sticky;
top: 10px;
z-index: 100;
}
.header-title-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
min-height: 60px;
}
.note-title {
flex: 1;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-icon {
width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #dee2e6;
border-radius: 8px;
background: white;
color: #495057;
transition: all 0.2s;
cursor: pointer;
}
.btn-icon:hover {
background: #f8f9fa;
border-color: #adb5bd;
transform: translateY(-1px);
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
background: #f8f9fa;
border-radius: 8px;
margin-right: 0.5rem;
}
.zoom-level {
min-width: 60px;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
color: #495057;
}
.header-meta-bar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
border-radius: 0 0 12px 12px;
font-size: 0.875rem;
color: #6c757d;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.meta-item i {
font-size: 1rem;
opacity: 0.7;
}
.meta-item a {
color: inherit;
text-decoration: none;
}
.meta-item a:hover {
color: #495057;
text-decoration: underline;
}
.tag-link {
color: #667eea;
}
.tag-link:hover {
color: #5a67d8;
}
/* Updated button styles for compact header */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btn-primary.btn-sm {
background: #667eea;
border-color: #667eea;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.btn-primary.btn-sm:hover {
background: #5a67d8;
border-color: #5a67d8;
}
.btn-secondary.btn-sm {
background: white;
border: 1px solid #dee2e6;
color: #495057;
}
.btn-secondary.btn-sm:hover {
background: #f8f9fa;
border-color: #adb5bd;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.header-title-bar {
padding: 0.75rem;
gap: 0.5rem;
}
.note-title {
font-size: 1.25rem;
}
.header-actions {
gap: 0.25rem;
}
.btn-sm {
padding: 0.375rem 0.5rem;
}
/* Hide button text on mobile */
.btn-text {
display: none;
}
.btn-sm i {
margin: 0;
}
.header-meta-bar {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
font-size: 0.8125rem;
}
.zoom-controls {
padding: 0.125rem;
}
.btn-icon {
width: 36px;
height: 36px;
}
}
.header-content {
@@ -723,6 +976,32 @@
font-size: 0.875rem;
line-height: 1.5;
margin: 0 0 0.5rem 0;
{% if g.user.preferences and g.user.preferences.note_preview_font and g.user.preferences.note_preview_font != 'system' %}
{% set font = g.user.preferences.note_preview_font %}
{% if font == 'sans-serif' %}
font-family: Arial, Helvetica, sans-serif;
{% elif font == 'serif' %}
font-family: "Times New Roman", Times, serif;
{% elif font == 'monospace' %}
font-family: "Courier New", Courier, monospace;
{% elif font == 'georgia' %}
font-family: Georgia, serif;
{% elif font == 'palatino' %}
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
{% elif font == 'garamond' %}
font-family: Garamond, serif;
{% elif font == 'bookman' %}
font-family: "Bookman Old Style", serif;
{% elif font == 'comic-sans' %}
font-family: "Comic Sans MS", cursive;
{% elif font == 'trebuchet' %}
font-family: "Trebuchet MS", sans-serif;
{% elif font == 'arial-black' %}
font-family: "Arial Black", sans-serif;
{% elif font == 'impact' %}
font-family: Impact, sans-serif;
{% endif %}
{% endif %}
}
.linked-note-meta {
@@ -857,10 +1136,227 @@
justify-content: center;
}
}
/* Wiki-style Links */
.wiki-link {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px dotted var(--primary-color);
transition: all 0.2s ease;
}
.wiki-link:hover {
border-bottom-style: solid;
text-decoration: none;
}
.wiki-link-broken {
color: #dc3545;
text-decoration: line-through;
cursor: not-allowed;
opacity: 0.7;
}
/* Wiki-style Embeds */
.wiki-embed {
margin: 1.5rem 0;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
background: #f8f9fa;
}
.wiki-embed-header {
padding: 0.75rem 1rem;
background: white;
border-bottom: 1px solid #dee2e6;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wiki-embed-title {
font-weight: 600;
color: #333;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wiki-embed-title:hover {
color: var(--primary-color);
}
.wiki-embed-content {
padding: 1rem;
background: white;
max-height: 400px;
overflow-y: auto;
}
.wiki-embed-content .markdown-content {
margin: 0;
}
.wiki-embed-error {
margin: 1rem 0;
padding: 0.75rem 1rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
color: #721c24;
font-style: italic;
}
/* Nested embeds have reduced padding */
.wiki-embed .wiki-embed {
margin: 1rem 0;
}
.wiki-embed .wiki-embed .wiki-embed-content {
max-height: 200px;
}
/* Font families based on user preferences */
{% if g.user.preferences and g.user.preferences.note_preview_font %}
{% set font = g.user.preferences.note_preview_font %}
{% if font == 'sans-serif' %}
.markdown-content { font-family: Arial, Helvetica, sans-serif; }
{% elif font == 'serif' %}
.markdown-content { font-family: "Times New Roman", Times, serif; }
{% elif font == 'monospace' %}
.markdown-content { font-family: "Courier New", Courier, monospace; }
{% elif font == 'georgia' %}
.markdown-content { font-family: Georgia, serif; }
{% elif font == 'palatino' %}
.markdown-content { font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; }
{% elif font == 'garamond' %}
.markdown-content { font-family: Garamond, serif; }
{% elif font == 'bookman' %}
.markdown-content { font-family: "Bookman Old Style", serif; }
{% elif font == 'comic-sans' %}
.markdown-content { font-family: "Comic Sans MS", cursive; }
{% elif font == 'trebuchet' %}
.markdown-content { font-family: "Trebuchet MS", sans-serif; }
{% elif font == 'arial-black' %}
.markdown-content { font-family: "Arial Black", sans-serif; }
{% elif font == 'impact' %}
.markdown-content { font-family: Impact, sans-serif; }
{% endif %}
{% endif %}
/* PDF Preview Styles */
.pdf-preview-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
}
.pdf-toolbar {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 8px 8px 0 0;
}
.pdf-viewer {
width: 100%;
height: 800px;
border: none;
background: #f8f9fa;
}
/* Image preview styles */
.image-preview-container {
text-align: center;
padding: 1rem;
}
.note-image {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: zoom-in;
}
.note-image:fullscreen {
cursor: zoom-out;
object-fit: contain;
padding: 2rem;
background: black;
}
/* Responsive PDF viewer */
@media (max-width: 768px) {
.pdf-viewer {
height: 600px;
}
.pdf-toolbar {
flex-wrap: wrap;
}
}
</style>
<script>
// PDF viewer controls
let pdfZoom = 1.0;
function pdfZoomIn() {
pdfZoom += 0.1;
updatePdfZoom();
}
function pdfZoomOut() {
if (pdfZoom > 0.5) {
pdfZoom -= 0.1;
updatePdfZoom();
}
}
function pdfZoomReset() {
pdfZoom = 1.0;
updatePdfZoom();
}
function updatePdfZoom() {
const viewer = document.getElementById('pdf-viewer');
const zoomLevel = document.getElementById('zoom-level');
if (viewer) {
viewer.style.transform = `scale(${pdfZoom})`;
viewer.style.transformOrigin = 'top center';
}
if (zoomLevel) {
zoomLevel.textContent = Math.round(pdfZoom * 100) + '%';
}
}
// Image viewer functions
function toggleFullscreen() {
const image = document.getElementById('note-image');
if (image) {
if (!document.fullscreenElement) {
image.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
document.exitFullscreen();
}
}
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize zoom level display for PDFs
const zoomLevel = document.getElementById('zoom-level');
if (zoomLevel) {
zoomLevel.textContent = '100%';
}
// Download dropdown functionality
const downloadBtn = document.getElementById('downloadDropdown');
const downloadMenu = downloadBtn.nextElementSibling;

File diff suppressed because it is too large Load Diff

View File

@@ -301,6 +301,57 @@
</div>
</div>
</div>
<!-- Note Preferences Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-notes"></i></span>
Note Preferences
</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_note_preferences') }}" class="modern-form">
<div class="form-group">
<label for="note_preview_font" class="form-label">Preview Font</label>
<select id="note_preview_font" name="note_preview_font" class="form-control">
<option value="system" {% if not g.user.preferences or g.user.preferences.note_preview_font == 'system' %}selected{% endif %}>System Default</option>
<option value="sans-serif" {% if g.user.preferences and g.user.preferences.note_preview_font == 'sans-serif' %}selected{% endif %}>Sans-serif (Arial, Helvetica)</option>
<option value="serif" {% if g.user.preferences and g.user.preferences.note_preview_font == 'serif' %}selected{% endif %}>Serif (Times, Georgia)</option>
<option value="monospace" {% if g.user.preferences and g.user.preferences.note_preview_font == 'monospace' %}selected{% endif %}>Monospace (Courier, Consolas)</option>
<option value="georgia" {% if g.user.preferences and g.user.preferences.note_preview_font == 'georgia' %}selected{% endif %}>Georgia</option>
<option value="palatino" {% if g.user.preferences and g.user.preferences.note_preview_font == 'palatino' %}selected{% endif %}>Palatino</option>
<option value="garamond" {% if g.user.preferences and g.user.preferences.note_preview_font == 'garamond' %}selected{% endif %}>Garamond</option>
<option value="bookman" {% if g.user.preferences and g.user.preferences.note_preview_font == 'bookman' %}selected{% endif %}>Bookman</option>
<option value="comic-sans" {% if g.user.preferences and g.user.preferences.note_preview_font == 'comic-sans' %}selected{% endif %}>Comic Sans MS</option>
<option value="trebuchet" {% if g.user.preferences and g.user.preferences.note_preview_font == 'trebuchet' %}selected{% endif %}>Trebuchet MS</option>
<option value="arial-black" {% if g.user.preferences and g.user.preferences.note_preview_font == 'arial-black' %}selected{% endif %}>Arial Black</option>
<option value="impact" {% if g.user.preferences and g.user.preferences.note_preview_font == 'impact' %}selected{% endif %}>Impact</option>
</select>
<span class="form-hint">Choose your preferred font for note content</span>
</div>
<div class="font-preview-section">
<label class="form-label">Preview</label>
<div id="font-preview" class="font-preview">
<h3>Sample Heading</h3>
<p>This is how your notes will appear with the selected font. The quick brown fox jumps over the lazy dog.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<ul>
<li>First item in a list</li>
<li>Second item with <strong>bold text</strong></li>
<li>Third item with <em>italic text</em></li>
</ul>
</div>
</div>
<button type="submit" class="btn btn-primary">
<span class="icon"><i class="ti ti-check"></i></span>
Save Preferences
</button>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -905,6 +956,57 @@
.card:nth-child(3) {
animation-delay: 0.2s;
}
.card:nth-child(4) {
animation-delay: 0.3s;
}
/* Font Preview Section */
.font-preview-section {
margin-top: 1.5rem;
}
.font-preview {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
background: #f8f9fa;
max-height: 300px;
overflow-y: auto;
}
.font-preview h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.font-preview p {
margin-bottom: 1rem;
line-height: 1.6;
}
.font-preview ul {
margin-left: 1.5rem;
}
.font-preview li {
margin-bottom: 0.5rem;
}
/* Font families for preview */
.font-system { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.font-sans-serif { font-family: Arial, Helvetica, sans-serif; }
.font-serif { font-family: "Times New Roman", Times, serif; }
.font-monospace { font-family: "Courier New", Courier, monospace; }
.font-georgia { font-family: Georgia, serif; }
.font-palatino { font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; }
.font-garamond { font-family: Garamond, serif; }
.font-bookman { font-family: "Bookman Old Style", serif; }
.font-comic-sans { font-family: "Comic Sans MS", cursive; }
.font-trebuchet { font-family: "Trebuchet MS", sans-serif; }
.font-arial-black { font-family: "Arial Black", sans-serif; }
.font-impact { font-family: Impact, sans-serif; }
</style>
<script>
@@ -1032,6 +1134,33 @@ function isValidUrl(string) {
}
}
// Font preview update
document.addEventListener('DOMContentLoaded', function() {
const fontSelect = document.getElementById('note_preview_font');
const fontPreview = document.getElementById('font-preview');
if (fontSelect && fontPreview) {
fontSelect.addEventListener('change', function() {
updateFontPreview(this.value);
});
// Set initial font
updateFontPreview(fontSelect.value);
}
function updateFontPreview(fontValue) {
// Remove all font classes
fontPreview.className = 'font-preview';
// Add the selected font class
if (fontValue !== 'system') {
fontPreview.classList.add('font-' + fontValue);
} else {
fontPreview.classList.add('font-system');
}
}
});
function resetAvatar() {
if (confirm('Reset to your default avatar? This will remove any custom avatar.')) {
const form = document.createElement('form');

View File

@@ -178,9 +178,9 @@
<div class="form-group input-icon">
<i>📧</i>
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com (optional)">
<label for="email">Email Address (Optional)</label>
<small class="form-text text-muted">Recommended for account recovery and notifications</small>
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com" required>
<label for="email">Email Address</label>
<small class="form-text text-muted">Required for account verification and recovery</small>
</div>
<div class="form-group input-icon">
@@ -234,7 +234,7 @@
</div>
<div class="verification-notice">
<p><i class="ti ti-bulb"></i> You can register without an email, but we recommend adding one for account recovery.</p>
<p><i class="ti ti-bulb"></i> A valid email address is required for registration. You will receive a verification email after signup.</p>
</div>
</form>
</div>

View File

@@ -47,9 +47,9 @@
<div class="form-group input-icon">
<i>📧</i>
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com (optional)">
<label for="email">Email Address (Optional)</label>
<small class="form-text text-muted">Recommended for account recovery</small>
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com" required>
<label for="email">Email Address</label>
<small class="form-text text-muted">Required for account verification and recovery</small>
</div>
<div class="form-group input-icon">

167
wiki_links.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Wiki-style link processing for Notes feature.
Supports [[note-title]] for links and ![[note-title]] for embedding.
"""
import re
from flask import g, url_for
from models import Note, NoteVisibility
from sqlalchemy import or_, and_
def process_wiki_links(content, current_note_id=None):
"""
Process wiki-style links in content.
- [[note-title]] becomes a link to the note
- ![[note-title]] embeds the note content
Args:
content: Markdown content with potential wiki links
current_note_id: ID of the current note (to avoid circular embeds)
Returns:
Processed content with wiki links replaced
"""
# Process embeds first (![[...]]) to avoid link processing inside embedded content
content = process_wiki_embeds(content, current_note_id)
# Then process regular links ([[...]])
content = process_wiki_regular_links(content)
return content
def process_wiki_regular_links(content):
"""Convert [[note-title]] to HTML links"""
def replace_link(match):
link_text = match.group(1).strip()
# Try to find the note by title or slug
note = find_note_by_reference(link_text)
if note:
# Create a link to the note
url = url_for('notes.view_note', slug=note.slug)
return f'<a href="{url}" class="wiki-link" title="View: {note.title}">{link_text}</a>'
else:
# Note not found - create a broken link indicator
return f'<span class="wiki-link-broken" title="Note not found: {link_text}">{link_text}</span>'
# Pattern to match [[text]] but not ![[text]]
pattern = r'(?<!\!)\[\[([^\]]+)\]\]'
return re.sub(pattern, replace_link, content)
def process_wiki_embeds(content, current_note_id=None):
"""Convert ![[note-title]] to embedded content"""
# Keep track of embedded notes to prevent infinite recursion
embedded_notes = set()
if current_note_id:
embedded_notes.add(current_note_id)
def replace_embed(match):
embed_ref = match.group(1).strip()
# Try to find the note by title or slug
note = find_note_by_reference(embed_ref)
if note:
# Check if we've already embedded this note (circular reference)
if note.id in embedded_notes:
return f'<div class="wiki-embed-error">Circular reference detected: {embed_ref}</div>'
# Check if user can view the note
if not note.can_user_view(g.user):
return f'<div class="wiki-embed-error">Access denied: {embed_ref}</div>'
# Add to embedded set
embedded_notes.add(note.id)
# Get the note content without frontmatter
from frontmatter_utils import parse_frontmatter
_, body = parse_frontmatter(note.content)
# Process any wiki links in the embedded content (but not embeds to avoid deep recursion)
embedded_content = process_wiki_regular_links(body)
# Render the embedded content
import markdown
html_content = markdown.markdown(embedded_content, extensions=['extra', 'codehilite', 'toc'])
# Create an embedded note block
embed_html = f'''
<div class="wiki-embed" data-note-id="{note.id}">
<div class="wiki-embed-header">
<a href="{url_for('notes.view_note', slug=note.slug)}" class="wiki-embed-title">
<i class="ti ti-file-text"></i> {note.title}
</a>
</div>
<div class="wiki-embed-content">
{html_content}
</div>
</div>'''
return embed_html
else:
# Note not found
return f'<div class="wiki-embed-error">Note not found: {embed_ref}</div>'
# Pattern to match ![[text]]
pattern = r'\!\[\[([^\]]+)\]\]'
return re.sub(pattern, replace_embed, content)
def find_note_by_reference(reference):
"""
Find a note by title or slug reference.
First tries exact slug match, then exact title match, then case-insensitive title match.
Args:
reference: The note reference (title or slug)
Returns:
Note object or None
"""
if not g.user or not g.user.company_id:
return None
# Build base query for notes the user can see
base_query = Note.query.filter(
Note.company_id == g.user.company_id,
Note.is_archived == False
).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
)
)
# Try exact slug match first
note = base_query.filter_by(slug=reference).first()
if note:
return note
# Try exact title match
note = base_query.filter_by(title=reference).first()
if note:
return note
# Try case-insensitive title match
note = base_query.filter(Note.title.ilike(reference)).first()
if note:
return note
# Try slug-ified version of the reference
import re
slugified = re.sub(r'[^\w\s-]', '', reference.lower())
slugified = re.sub(r'[-\s]+', '-', slugified).strip('-')
if slugified != reference:
note = base_query.filter_by(slug=slugified).first()
if note:
return note
return None