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""
+
+ # 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 @@
+
+
+
+
+
+ Markdown & Wiki Syntax Help
+
+
+
+
Wiki-style Links
+
+ [[Note Title]] - Create a link to another note
+ ![[Note Title]] - Embed another note's content
+
+
Wiki links work with note titles or slugs. If a note isn't found, it will show as a broken link.
+
+
+
+
Basic Markdown
+
+ # Heading 1 / ## Heading 2 / ### Heading 3
+ **bold** / *italic* / `code`
+ [Link text](url) - Regular links
+  - Images
+ - Item - Bullet lists
+ 1. Item - Numbered lists
+ - [ ] Task - Checklists
+ > Quote - Blockquotes
+
+
+
+
+
Advanced Markdown
+
+ ```language
code block
``` - Code blocks with syntax highlighting
+ | Header | Header |
|--------|--------|
| Cell | Cell | - Tables
+ --- - Horizontal rule
+
+
+
+
+
Tips
+
+ - Use frontmatter (YAML at the top) to set metadata
+ - Press Ctrl+S to save quickly
+ - The preview updates in real-time as you type
+ - Wiki embeds show a preview of the linked note
+
+
+
+
+
+