Allow uploading of files and folders.
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user