From f98e8f3e71c135e10490ca6aecb68e933e0c2eed Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Mon, 28 Jul 2025 11:07:40 +0200 Subject: [PATCH] Allow uploading of files and folders. --- app.py | 67 +- ...dc91_add_file_based_note_support_fields.py | 45 + ...7d9e3f_add_note_preview_font_preference.py | 31 + models/note.py | 91 +- models/note_attachment.py | 92 ++ models/user.py | 3 + requirements.txt | 1 + routes/notes_api.py | 357 +++++- templates/note_editor.html | 570 ++++++++- templates/note_view.html | 194 +++ templates/notes_list.html | 1066 ++++++++++++++++- templates/profile.html | 129 ++ wiki_links.py | 167 +++ 13 files changed, 2805 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/275ef106dc91_add_file_based_note_support_fields.py create mode 100644 migrations/versions/4a5b2c7d9e3f_add_note_preview_font_preference.py create mode 100644 models/note_attachment.py create mode 100644 wiki_links.py diff --git a/app.py b/app.py index ce81a86..3b158a2 100644 --- a/app.py +++ b/app.py @@ -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/') +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: @@ -1138,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(): @@ -1233,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 @@ -1241,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) @@ -1250,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') diff --git a/migrations/versions/275ef106dc91_add_file_based_note_support_fields.py b/migrations/versions/275ef106dc91_add_file_based_note_support_fields.py new file mode 100644 index 0000000..1149f07 --- /dev/null +++ b/migrations/versions/275ef106dc91_add_file_based_note_support_fields.py @@ -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') \ No newline at end of file diff --git a/migrations/versions/4a5b2c7d9e3f_add_note_preview_font_preference.py b/migrations/versions/4a5b2c7d9e3f_add_note_preview_font_preference.py new file mode 100644 index 0000000..bef5f1e --- /dev/null +++ b/migrations/versions/4a5b2c7d9e3f_add_note_preview_font_preference.py @@ -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') \ No newline at end of file diff --git a/models/note.py b/models/note.py index ac6e9f0..5f3daf8 100644 --- a/models/note.py +++ b/models/note.py @@ -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 @@ -262,6 +280,77 @@ class Note(db.Model): """Check if this note has any active share links""" 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: + return f'/uploads/notes/{self.file_path}' + 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""" diff --git a/models/note_attachment.py b/models/note_attachment.py new file mode 100644 index 0000000..3ce5610 --- /dev/null +++ b/models/note_attachment.py @@ -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'' + + @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' \ No newline at end of file diff --git a/models/user.py b/models/user.py index a806c34..85a1d43 100644 --- a/models/user.py +++ b/models/user.py @@ -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 diff --git a/requirements.txt b/requirements.txt index e8e7320..6df92bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/routes/notes_api.py b/routes/notes_api.py index 7a8657c..3ee106a 100644 --- a/routes/notes_api.py +++ b/routes/notes_api.py @@ -557,4 +557,359 @@ def update_note_share(share_id): }) except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': str(e)}), 500 \ No newline at end of file + 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 + import time + from werkzeug.utils import secure_filename + from PIL import Image + from flask import current_app, url_for + + 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 + + # Check if file type is allowed + if not Note.allowed_file(file.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 + original_filename = secure_filename(file.filename) + file_type = Note.get_file_type_from_filename(original_filename) + + # Create upload directory in persistent volume + upload_base = os.path.join('/data', 'uploads', 'notes') + if file_type == 'image': + upload_dir = os.path.join(upload_base, 'images') + elif file_type == 'markdown': + upload_dir = os.path.join(upload_base, 'markdown') + elif file_type == 'text': + upload_dir = os.path.join(upload_base, 'text') + else: + upload_dir = os.path.join(upload_base, 'documents') + + os.makedirs(upload_dir, exist_ok=True) + + # Generate unique filename + timestamp = int(time.time()) + filename = f"{g.user.company_id}_{g.user.id}_{timestamp}_{original_filename}" + file_path = os.path.join(upload_dir, filename) + relative_path = os.path.join(file_type + 's' if file_type != 'markdown' else file_type, filename) + + # Save the file + file.save(file_path) + + # Get file size + file_size = os.path.getsize(file_path) + + # 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 = f"![{title}](/uploads/notes/{relative_path})" + + # 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 = f"[Download {original_filename}](/uploads/notes/{relative_path})" + 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=file.content_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: + note.folder = folder + + # Ensure folder exists in database + 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 + + # Check if folder exists + existing_folder = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=current_path + ).first() + + if not existing_folder: + # Create 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) + + # 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() + + return jsonify({ + 'success': True, + 'note': { + 'id': note.id, + 'title': note.title, + 'slug': note.slug, + 'file_type': note.file_type, + 'file_url': note.file_url, + '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 + from flask import current_app + + for note in notes: + if note.file_path: + file_path = os.path.join(current_app.root_path, note.file_path.lstrip('/')) + if os.path.exists(file_path): + try: + 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 diff --git a/templates/note_editor.html b/templates/note_editor.html index 9b33b24..02c9754 100644 --- a/templates/note_editor.html +++ b/templates/note_editor.html @@ -19,6 +19,10 @@

+
+ + +
@@ -222,12 +279,33 @@ + +
+ +
+ + +
{{ note.content if note else '# New Note\n\nStart writing here...' }}
+ + + + + +
+
+

+ + Note Preferences +

+
+
+ +
+ + + Choose your preferred font for note content +
+ +
+ +
+

Sample Heading

+

This is how your notes will appear with the selected font. The quick brown fox jumps over the lazy dog.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
    +
  • First item in a list
  • +
  • Second item with bold text
  • +
  • Third item with italic text
  • +
+
+
+ + + +
+
@@ -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; }