Allow uploading of files and folders.
This commit is contained in:
67
app.py
67
app.py
@@ -438,6 +438,39 @@ def favicon_16():
|
|||||||
def apple_touch_icon():
|
def apple_touch_icon():
|
||||||
return send_from_directory(app.static_folder, 'apple-touch-icon.png', mimetype='image/png')
|
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('/')
|
@app.route('/')
|
||||||
def home():
|
def home():
|
||||||
if g.user:
|
if g.user:
|
||||||
@@ -1138,6 +1171,31 @@ def profile():
|
|||||||
|
|
||||||
return render_template('profile.html', title='My Profile', user=user)
|
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'])
|
@app.route('/update-avatar', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def update_avatar():
|
def update_avatar():
|
||||||
@@ -1233,7 +1291,7 @@ def upload_avatar():
|
|||||||
unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}"
|
unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}"
|
||||||
|
|
||||||
# Create user avatar directory if it doesn't exist
|
# 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)
|
os.makedirs(avatar_dir, exist_ok=True)
|
||||||
|
|
||||||
# Save the file
|
# Save the file
|
||||||
@@ -1241,8 +1299,9 @@ def upload_avatar():
|
|||||||
file.save(file_path)
|
file.save(file_path)
|
||||||
|
|
||||||
# Delete old avatar file if it exists and is a local upload
|
# Delete old avatar file if it exists and is a local upload
|
||||||
if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'):
|
if user.avatar_url and user.avatar_url.startswith('/uploads/avatars/'):
|
||||||
old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/'))
|
old_filename = user.avatar_url.split('/')[-1]
|
||||||
|
old_file_path = os.path.join(avatar_dir, old_filename)
|
||||||
if os.path.exists(old_file_path):
|
if os.path.exists(old_file_path):
|
||||||
try:
|
try:
|
||||||
os.remove(old_file_path)
|
os.remove(old_file_path)
|
||||||
@@ -1250,7 +1309,7 @@ def upload_avatar():
|
|||||||
logger.warning(f"Failed to delete old avatar: {e}")
|
logger.warning(f"Failed to delete old avatar: {e}")
|
||||||
|
|
||||||
# Update user's avatar URL
|
# 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()
|
db.session.commit()
|
||||||
|
|
||||||
flash('Avatar uploaded successfully!', 'success')
|
flash('Avatar uploaded successfully!', 'success')
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -52,6 +52,18 @@ class Note(db.Model):
|
|||||||
# Pin important notes
|
# Pin important notes
|
||||||
is_pinned = db.Column(db.Boolean, default=False)
|
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
|
# Soft delete
|
||||||
is_archived = db.Column(db.Boolean, default=False)
|
is_archived = db.Column(db.Boolean, default=False)
|
||||||
archived_at = db.Column(db.DateTime, nullable=True)
|
archived_at = db.Column(db.DateTime, nullable=True)
|
||||||
@@ -162,12 +174,18 @@ class Note(db.Model):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
def render_html(self):
|
def render_html(self):
|
||||||
"""Render markdown content to HTML"""
|
"""Render markdown content to HTML with Wiki-style link support"""
|
||||||
try:
|
try:
|
||||||
import markdown
|
import markdown
|
||||||
from frontmatter_utils import parse_frontmatter
|
from frontmatter_utils import parse_frontmatter
|
||||||
|
from wiki_links import process_wiki_links
|
||||||
|
|
||||||
# Extract body content without frontmatter
|
# Extract body content without frontmatter
|
||||||
_, body = parse_frontmatter(self.content)
|
_, 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
|
# Use extensions for better markdown support
|
||||||
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
|
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
|
||||||
return html
|
return html
|
||||||
@@ -262,6 +280,77 @@ class Note(db.Model):
|
|||||||
"""Check if this note has any active share links"""
|
"""Check if this note has any active share links"""
|
||||||
return any(s.is_valid() for s in self.shares)
|
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):
|
class NoteLink(db.Model):
|
||||||
"""Links between notes for creating relationships"""
|
"""Links between notes for creating relationships"""
|
||||||
|
|||||||
92
models/note_attachment.py
Normal file
92
models/note_attachment.py
Normal 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'
|
||||||
@@ -183,6 +183,9 @@ class UserPreferences(db.Model):
|
|||||||
time_format = db.Column(db.String(10), default='24h')
|
time_format = db.Column(db.String(10), default='24h')
|
||||||
time_format_24h = db.Column(db.Boolean, default=True) # True for 24h, False for 12h
|
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 tracking preferences
|
||||||
time_rounding_minutes = db.Column(db.Integer, default=0) # 0, 5, 10, 15, 30, 60
|
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
|
round_to_nearest = db.Column(db.Boolean, default=False) # False=round down, True=round to nearest
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ Flask-Migrate==3.1.0
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
markdown==3.4.4
|
markdown==3.4.4
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
|
Pillow==10.3.0
|
||||||
|
|||||||
@@ -557,4 +557,359 @@ def update_note_share(share_id):
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<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">
|
<button type="button" class="btn btn-secondary" id="settings-toggle">
|
||||||
<span class="icon"><i class="ti ti-settings"></i></span>
|
<span class="icon"><i class="ti ti-settings"></i></span>
|
||||||
Settings
|
Settings
|
||||||
@@ -132,6 +136,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</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></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 -->
|
<!-- Editor Form -->
|
||||||
<form method="POST" id="note-form">
|
<form method="POST" id="note-form">
|
||||||
<input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required>
|
<input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required>
|
||||||
@@ -222,12 +279,33 @@
|
|||||||
<i class="ti ti-minus"></i>
|
<i class="ti ti-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Ace Editor Container -->
|
<!-- Ace Editor Container -->
|
||||||
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '# New Note\n\nStart writing here...' }}</div>
|
<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>
|
<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 -->
|
<!-- Editor Footer -->
|
||||||
<div class="editor-footer">
|
<div class="editor-footer">
|
||||||
<div class="editor-stats">
|
<div class="editor-stats">
|
||||||
@@ -601,6 +679,196 @@
|
|||||||
margin-top: 2rem;
|
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 */
|
/* Animations */
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from {
|
from {
|
||||||
@@ -952,7 +1220,8 @@ function initializeAceEditor() {
|
|||||||
useSoftTabs: true,
|
useSoftTabs: true,
|
||||||
wrap: true,
|
wrap: true,
|
||||||
showInvisibles: false,
|
showInvisibles: false,
|
||||||
scrollPastEnd: 0.5
|
scrollPastEnd: 0.5,
|
||||||
|
behavioursEnabled: true // Keep auto-closing brackets enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial content from hidden textarea
|
// Set initial content from hidden textarea
|
||||||
@@ -1057,17 +1326,37 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Settings toggle
|
// Settings toggle
|
||||||
const settingsBtn = document.getElementById('settings-toggle');
|
const settingsBtn = document.getElementById('settings-toggle');
|
||||||
const settingsPanel = document.getElementById('settings-panel');
|
const settingsPanel = document.getElementById('settings-panel');
|
||||||
|
const helpBtn = document.getElementById('help-toggle');
|
||||||
|
const helpPanel = document.getElementById('help-panel');
|
||||||
|
|
||||||
settingsBtn.addEventListener('click', function() {
|
settingsBtn.addEventListener('click', function() {
|
||||||
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
|
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
|
||||||
settingsPanel.style.display = 'block';
|
settingsPanel.style.display = 'block';
|
||||||
this.classList.add('active');
|
this.classList.add('active');
|
||||||
|
// Close help panel if open
|
||||||
|
helpPanel.style.display = 'none';
|
||||||
|
helpBtn.classList.remove('active');
|
||||||
} else {
|
} else {
|
||||||
settingsPanel.style.display = 'none';
|
settingsPanel.style.display = 'none';
|
||||||
this.classList.remove('active');
|
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
|
// Preview toggle
|
||||||
const previewToggle = document.getElementById('preview-toggle');
|
const previewToggle = document.getElementById('preview-toggle');
|
||||||
const previewPanel = document.getElementById('preview-panel');
|
const previewPanel = document.getElementById('preview-panel');
|
||||||
@@ -1130,6 +1419,285 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
syncContentAndUpdatePreview(false);
|
syncContentAndUpdatePreview(false);
|
||||||
syncSettingsToHiddenFields(); // Sync to hidden fields
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -152,9 +152,31 @@
|
|||||||
|
|
||||||
<!-- Note Content -->
|
<!-- Note Content -->
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
|
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
|
||||||
|
<!-- PDF Preview -->
|
||||||
|
<div class="pdf-preview-container">
|
||||||
|
<div class="pdf-toolbar">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="pdfZoomIn()">
|
||||||
|
<i class="ti ti-zoom-in"></i> Zoom In
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="pdfZoomOut()">
|
||||||
|
<i class="ti ti-zoom-out"></i> Zoom Out
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="pdfZoomReset()">
|
||||||
|
<i class="ti ti-zoom-reset"></i> Reset
|
||||||
|
</button>
|
||||||
|
<a href="{{ note.file_url }}" class="btn btn-sm btn-primary" download>
|
||||||
|
<i class="ti ti-download"></i> Download PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<iframe id="pdf-viewer" src="{{ note.file_url }}" class="pdf-viewer"></iframe>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Regular Content -->
|
||||||
<div class="markdown-content">
|
<div class="markdown-content">
|
||||||
{{ note.render_html()|safe }}
|
{{ note.render_html()|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Linked Notes Section -->
|
<!-- Linked Notes Section -->
|
||||||
@@ -857,9 +879,181 @@
|
|||||||
justify-content: center;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive PDF viewer */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pdf-viewer {
|
||||||
|
height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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');
|
||||||
|
if (viewer) {
|
||||||
|
viewer.style.transform = `scale(${pdfZoom})`;
|
||||||
|
viewer.style.transformOrigin = 'top center';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Download dropdown functionality
|
// Download dropdown functionality
|
||||||
const downloadBtn = document.getElementById('downloadDropdown');
|
const downloadBtn = document.getElementById('downloadDropdown');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -301,6 +301,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -905,6 +956,57 @@
|
|||||||
.card:nth-child(3) {
|
.card:nth-child(3) {
|
||||||
animation-delay: 0.2s;
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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() {
|
function resetAvatar() {
|
||||||
if (confirm('Reset to your default avatar? This will remove any custom avatar.')) {
|
if (confirm('Reset to your default avatar? This will remove any custom avatar.')) {
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
|
|||||||
167
wiki_links.py
Normal file
167
wiki_links.py
Normal 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
|
||||||
Reference in New Issue
Block a user