Allow uploading of files and folders.

This commit is contained in:
2025-07-28 11:07:40 +02:00
committed by Jens Luedicke
parent 87471e033e
commit f98e8f3e71
13 changed files with 2805 additions and 8 deletions

View File

@@ -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"![{title}](/uploads/notes/{relative_path})"
# Get image dimensions
try:
with Image.open(file_path) as img:
image_width, image_height = img.size
except:
image_width = image_height = None
else:
# For other documents
title = request.form.get('title') or clean_filename_for_title(original_filename)
content = f"[Download {original_filename}](/uploads/notes/{relative_path})"
image_width = image_height = None
# Create the note
note = Note(
title=title,
content=content,
visibility=NoteVisibility.PRIVATE,
created_by_id=g.user.id,
company_id=g.user.company_id,
is_file_based=True,
file_path=relative_path,
file_type=file_type,
original_filename=original_filename,
file_size=file_size,
mime_type=file.content_type
)
if file_type == 'image' and image_width:
note.image_width = image_width
note.image_height = image_height
# Set folder if provided
folder = request.form.get('folder')
if folder:
note.folder = folder
# Ensure folder exists in database
folder_parts = folder.split('/')
current_path = ''
for i, part in enumerate(folder_parts):
if i == 0:
current_path = part
else:
current_path = current_path + '/' + part
# Check if folder exists
existing_folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=current_path
).first()
if not existing_folder:
# Create folder
parent_path = '/'.join(folder_parts[:i]) if i > 0 else None
new_folder = NoteFolder(
name=part,
path=current_path,
parent_path=parent_path,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(new_folder)
# Set tags if provided
tags = request.form.get('tags')
if tags:
note.tags = tags
# Generate slug
note.generate_slug()
try:
db.session.add(note)
db.session.commit()
return jsonify({
'success': True,
'note': {
'id': note.id,
'title': note.title,
'slug': note.slug,
'file_type': note.file_type,
'file_url': note.file_url,
'url': url_for('notes.view_note', slug=note.slug)
}
})
except Exception as e:
db.session.rollback()
# Delete uploaded file on error
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/bulk-move', methods=['POST'])
@login_required
@company_required
def bulk_move_notes():
"""Move multiple notes to a different folder."""
data = request.get_json()
if not data or 'note_ids' not in data:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
note_ids = data.get('note_ids', [])
folder = data.get('folder', '')
if not note_ids:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
try:
# Get all notes and verify ownership
notes = Note.query.filter(
Note.id.in_(note_ids),
Note.company_id == g.user.company_id
).all()
if len(notes) != len(note_ids):
return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403
# Update folder for all notes
for note in notes:
note.folder = folder if folder else None
db.session.commit()
return jsonify({
'success': True,
'message': f'Moved {len(notes)} note(s) to {"folder: " + folder if folder else "root"}'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/bulk-delete', methods=['POST'])
@login_required
@company_required
def bulk_delete_notes():
"""Delete multiple notes."""
data = request.get_json()
if not data or 'note_ids' not in data:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
note_ids = data.get('note_ids', [])
if not note_ids:
return jsonify({'success': False, 'error': 'No notes specified'}), 400
try:
# Get all notes and verify ownership
notes = Note.query.filter(
Note.id.in_(note_ids),
Note.company_id == g.user.company_id
).all()
if len(notes) != len(note_ids):
return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403
# Delete files if they exist
import os
from flask import current_app
for note in notes:
if note.file_path:
file_path = os.path.join(current_app.root_path, note.file_path.lstrip('/'))
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass # Continue even if file deletion fails
# Delete all notes
for note in notes:
db.session.delete(note)
db.session.commit()
return jsonify({
'success': True,
'message': f'Deleted {len(notes)} note(s)'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500