diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5486777 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(docker exec:*)", + "Bash(docker restart:*)", + "Bash(docker logs:*)", + "Bash(grep:*)", + "Bash(chmod:*)", + "Bash(python:*)", + "Bash(pkill:*)", + "Bash(curl:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/app.py b/app.py index 4124c5a..69edd45 100644 --- a/app.py +++ b/app.py @@ -1,52 +1,41 @@ # Standard library imports import base64 -import csv import io import json import logging import os import re -import tempfile -import time as time_module import uuid -import zipfile from datetime import datetime, time, timedelta from functools import wraps -from urllib.parse import unquote # Third-party imports import markdown -import pandas as pd import qrcode from dotenv import load_dotenv from flask import (Flask, Response, abort, flash, g, jsonify, redirect, render_template, request, send_file, session, url_for) from flask_mail import Mail, Message from sqlalchemy import and_, func, or_ -from werkzeug.security import check_password_hash from werkzeug.utils import secure_filename # Local application imports from data_export import (export_analytics_csv, export_analytics_excel, - export_team_hours_to_csv, export_team_hours_to_excel, export_to_csv, export_to_excel) -from data_formatting import (format_burndown_data, format_duration, - format_graph_data, format_table_data, - format_team_data, prepare_export_data, - prepare_team_hours_export_data) -from frontmatter_utils import parse_frontmatter +from data_formatting import (format_burndown_data, format_graph_data, + format_table_data, format_team_data, + prepare_export_data) from migrate_db import (get_db_path, migrate_data as migrate_data_from_db, migrate_postgresql_schema, migrate_task_system, migrate_to_company_model, migrate_work_config_data, run_all_migrations) from models import (AccountType, Announcement, BrandingSettings, Comment, CommentVisibility, Company, CompanySettings, - CompanyWorkConfig, DashboardWidget, Note, NoteFolder, - NoteLink, NoteVisibility, Project, ProjectCategory, Role, - Sprint, SprintStatus, SubTask, SystemEvent, SystemSettings, - Task, TaskDependency, TaskPriority, TaskStatus, Team, - TimeEntry, User, UserDashboard, UserPreferences, - WidgetTemplate, WidgetType, WorkConfig, WorkRegion, db) + CompanyWorkConfig, DashboardWidget, Project, ProjectCategory, + Role, Sprint, SprintStatus, SubTask, SystemEvent, + SystemSettings, Task, TaskDependency, TaskPriority, + TaskStatus, Team, TimeEntry, User, UserDashboard, + UserPreferences, WidgetType, WorkConfig, WorkRegion, db) from password_utils import PasswordValidator from time_utils import (apply_time_rounding, format_date_by_preference, format_datetime_by_preference, format_duration_readable, @@ -88,6 +77,15 @@ mail = Mail(app) # Initialize the database with the app db.init_app(app) +# Import and register blueprints +from routes.notes import notes_bp +from routes.notes_download import notes_download_bp +from routes.notes_api import notes_api_bp + +app.register_blueprint(notes_bp) +app.register_blueprint(notes_download_bp) +app.register_blueprint(notes_api_bp) + # Consolidated migration using migrate_db module def run_migrations(): """Run all database migrations using the consolidated migrate_db module.""" @@ -1615,1127 +1613,8 @@ def contact(): # Notes Management Routes -@app.route('/notes') -@login_required -@company_required -def notes_list(): - """List all notes accessible to the user""" - # Get filter parameters - visibility_filter = request.args.get('visibility', 'all') - tag_filter = request.args.get('tag') - folder_filter = request.args.get('folder') - search_query = request.args.get('search', request.args.get('q')) - - # Base query - all notes in user's company - query = Note.query.filter_by(company_id=g.user.company_id, is_archived=False) - - # Apply visibility filter - if visibility_filter == 'private': - query = query.filter_by(created_by_id=g.user.id, visibility=NoteVisibility.PRIVATE) - elif visibility_filter == 'team': - query = query.filter_by(visibility=NoteVisibility.TEAM) - if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]: - query = query.filter_by(team_id=g.user.team_id) - elif visibility_filter == 'company': - query = query.filter_by(visibility=NoteVisibility.COMPANY) - else: # 'all' - show all accessible notes - # Complex filter for visibility - conditions = [ - # User's own notes - Note.created_by_id == g.user.id, - # Company-wide notes - Note.visibility == NoteVisibility.COMPANY, - # Team notes if user is in the team - and_( - Note.visibility == NoteVisibility.TEAM, - Note.team_id == g.user.team_id - ) - ] - # Admins can see all team notes - if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]: - conditions.append(Note.visibility == NoteVisibility.TEAM) - - query = query.filter(or_(*conditions)) - - # Apply tag filter - if tag_filter: - query = query.filter(Note.tags.like(f'%{tag_filter}%')) - - # Apply folder filter - if folder_filter: - query = query.filter_by(folder=folder_filter) - - # Apply search - if search_query: - query = query.filter( - db.or_( - Note.title.ilike(f'%{search_query}%'), - Note.content.ilike(f'%{search_query}%'), - Note.tags.ilike(f'%{search_query}%') - ) - ) - - # Order by pinned first, then by updated date - notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all() - - # Get all unique tags for filter dropdown and count them - all_tags = set() - tag_counts = {} - visibility_counts = {'private': 0, 'team': 0, 'company': 0} - - for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all(): - if note.can_user_view(g.user): - # Count tags - note_tags = note.get_tags_list() - all_tags.update(note_tags) - for tag in note_tags: - tag_counts[tag] = tag_counts.get(tag, 0) + 1 - - # Count visibility - visibility_counts[note.visibility.value.lower()] = visibility_counts.get(note.visibility.value.lower(), 0) + 1 - - # Get all unique folders for filter dropdown - all_folders = set() - - # Get folders from NoteFolder table - folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all() - for folder in folder_records: - all_folders.add(folder.path) - - # Also get folders from notes (for backward compatibility) - folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all() - for note in folder_notes: - if note.folder: - all_folders.add(note.folder) - - # Get projects for filter - projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all() - - # Build folder tree structure for sidebar - folder_counts = {} - for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all(): - if note.folder and note.can_user_view(g.user): - # Add this folder and all parent folders - parts = note.folder.split('/') - for i in range(len(parts)): - folder_path = '/'.join(parts[:i+1]) - folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0) - - # Initialize counts for empty folders - for folder_path in all_folders: - if folder_path not in folder_counts: - folder_counts[folder_path] = 0 - - # Build folder tree structure - folder_tree = {} - for folder in sorted(all_folders): - parts = folder.split('/') - current = folder_tree - - for i, part in enumerate(parts): - if i == len(parts) - 1: - # Leaf folder - current[folder] = {} - else: - # Navigate to parent - parent_path = '/'.join(parts[:i+1]) - if parent_path not in current: - current[parent_path] = {} - current = current[parent_path] - - return render_template('notes_list.html', - title='Notes', - notes=notes, - visibility_filter=visibility_filter, - tag_filter=tag_filter, - folder_filter=folder_filter, - search_query=search_query, - all_tags=sorted(list(all_tags)), - all_folders=sorted(list(all_folders)), - folder_tree=folder_tree, - folder_counts=folder_counts, - tag_counts=tag_counts, - visibility_counts=visibility_counts, - projects=projects, - NoteVisibility=NoteVisibility) -@app.route('/notes/new', methods=['GET', 'POST']) -@login_required -@company_required -def create_note(): - """Create a new note""" - if request.method == 'POST': - title = request.form.get('title', '').strip() - content = request.form.get('content', '').strip() - visibility = request.form.get('visibility', 'Private') - folder = request.form.get('folder', '').strip() - tags = request.form.get('tags', '').strip() - project_id = request.form.get('project_id') - task_id = request.form.get('task_id') - - # Validate - if not title: - flash('Title is required', 'error') - return redirect(url_for('create_note')) - - if not content: - flash('Content is required', 'error') - return redirect(url_for('create_note')) - - try: - # Parse tags - tag_list = [] - if tags: - tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()] - - # Create note - note = Note( - title=title, - content=content, - visibility=NoteVisibility[visibility.upper()], # Convert to uppercase for enum access - folder=folder if folder else None, - tags=','.join(tag_list) if tag_list else None, - created_by_id=g.user.id, - company_id=g.user.company_id - ) - - # Sync metadata from frontmatter if present - note.sync_from_frontmatter() - - # Set team_id if visibility is Team - if visibility == 'Team' and g.user.team_id: - note.team_id = g.user.team_id - - # Set optional associations - if project_id: - project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first() - if project: - note.project_id = project.id - - if task_id: - task = Task.query.filter_by(id=task_id).first() - if task and task.project.company_id == g.user.company_id: - note.task_id = task.id - - # Generate slug - note.slug = note.generate_slug() - - db.session.add(note) - db.session.commit() - - flash('Note created successfully', 'success') - return redirect(url_for('view_note', slug=note.slug)) - - except Exception as e: - db.session.rollback() - logger.error(f"Error creating note: {str(e)}") - flash('Error creating note', 'error') - return redirect(url_for('create_note')) - - # GET request - show form - projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all() - tasks = [] - - # Get all existing folders for suggestions - all_folders = set() - - # Get folders from NoteFolder table - folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all() - for folder in folder_records: - all_folders.add(folder.path) - - # Also get folders from notes (for backward compatibility) - folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all() - for note in folder_notes: - if note.folder: - all_folders.add(note.folder) - - return render_template('note_editor.html', - title='New Note', - note=None, - projects=projects, - tasks=tasks, - all_folders=sorted(list(all_folders)), - NoteVisibility=NoteVisibility) - - -@app.route('/notes/') -@login_required -@company_required -def view_note(slug): - """View a specific note""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_view(g.user): - abort(403) - - # Get linked notes - outgoing_links = [] - incoming_links = [] - - for link in note.outgoing_links: - if link.target_note.can_user_view(g.user) and not link.target_note.is_archived: - outgoing_links.append(link) - - for link in note.incoming_links: - if link.source_note.can_user_view(g.user) and not link.source_note.is_archived: - incoming_links.append(link) - - # Get linkable notes for the modal - linkable_notes = [] - if note.can_user_edit(g.user): - # Get all notes the user can view - all_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all() - for n in all_notes: - if n.id != note.id and n.can_user_view(g.user): - # Check if not already linked - already_linked = any(link.target_note_id == n.id for link in note.outgoing_links) - already_linked = already_linked or any(link.source_note_id == n.id for link in note.incoming_links) - if not already_linked: - linkable_notes.append(n) - - return render_template('note_view.html', - title=note.title, - note=note, - outgoing_links=outgoing_links, - incoming_links=incoming_links, - linkable_notes=linkable_notes, - can_edit=note.can_user_edit(g.user)) - - -@app.route('/notes//mindmap') -@login_required -@company_required -def view_note_mindmap(slug): - """View a note as a mind map""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_view(g.user): - abort(403) - - return render_template('note_mindmap.html', - title=f"{note.title} - Mind Map", - note=note) - - -@app.route('/notes//download/') -@login_required -@company_required -def download_note(slug, format): - """Download a note in various formats""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_view(g.user): - abort(403) - - # Prepare filename - safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) - timestamp = datetime.now().strftime('%Y%m%d') - - if format == 'md': - # Download as Markdown with frontmatter - content = note.content - response = Response(content, mimetype='text/markdown') - response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.md"' - return response - - elif format == 'html': - # Download as HTML - html_content = f""" - - - - {note.title} - - - - - {note.render_html()} - -""" - response = Response(html_content, mimetype='text/html') - response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.html"' - return response - - elif format == 'txt': - # Download as plain text - metadata, body = parse_frontmatter(note.content) - - # Create plain text version - text_content = f"{note.title}\n{'=' * len(note.title)}\n\n" - text_content += f"Author: {note.created_by.username}\n" - text_content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n" - text_content += f"Updated: {note.updated_at.strftime('%Y-%m-%d %H:%M')}\n" - text_content += f"Visibility: {note.visibility.value}\n" - if note.folder: - text_content += f"Folder: {note.folder}\n" - if note.tags: - text_content += f"Tags: {note.tags}\n" - text_content += "\n" + "-" * 40 + "\n\n" - - # Remove markdown formatting - text_body = body - # Remove headers markdown - text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE) - # Remove emphasis - text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body) - text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body) - # Remove links but keep text - text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body) - # Remove images - text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body) - # Remove code blocks markers - text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL) - text_body = re.sub(r'`([^`]+)`', r'\1', text_body) - - text_content += text_body - - response = Response(text_content, mimetype='text/plain') - response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.txt"' - return response - - else: - abort(404) - - -@app.route('/notes/download-bulk', methods=['POST']) -@login_required -@company_required -def download_notes_bulk(): - """Download multiple notes as a zip file""" - - note_ids = request.form.getlist('note_ids[]') - format = request.form.get('format', 'md') - - if not note_ids: - flash('No notes selected for download', 'error') - return redirect(url_for('notes_list')) - - # Create a temporary file for the zip - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - - try: - with zipfile.ZipFile(temp_file.name, 'w') as zipf: - for note_id in note_ids: - note = Note.query.filter_by(id=int(note_id), company_id=g.user.company_id).first() - if note and note.can_user_view(g.user): - # Get content based on format - safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) - - if format == 'md': - content = note.content - filename = f"{safe_filename}.md" - elif format == 'html': - content = f""" - - - - {note.title} - - - -

{note.title}

- {note.render_html()} - -""" - filename = f"{safe_filename}.html" - else: # txt - metadata, body = parse_frontmatter(note.content) - content = f"{note.title}\n{'=' * len(note.title)}\n\n{body}" - filename = f"{safe_filename}.txt" - - # Add file to zip - zipf.writestr(filename, content) - - # Send the zip file - temp_file.seek(0) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - - return send_file( - temp_file.name, - mimetype='application/zip', - as_attachment=True, - download_name=f'notes_{timestamp}.zip' - ) - - finally: - # Clean up temp file after sending - os.unlink(temp_file.name) - - -@app.route('/notes/folder//download/') -@login_required -@company_required -def download_folder(folder_path, format): - """Download all notes in a folder as a zip file""" - - # Decode folder path (replace URL encoding) - folder_path = unquote(folder_path) - - # Get all notes in this folder - notes = Note.query.filter_by( - company_id=g.user.company_id, - folder=folder_path, - is_archived=False - ).all() - - # Filter notes user can view - viewable_notes = [note for note in notes if note.can_user_view(g.user)] - - if not viewable_notes: - flash('No notes found in this folder or you don\'t have permission to view them.', 'warning') - return redirect(url_for('notes_list')) - - # Create a temporary file for the zip - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - - try: - with zipfile.ZipFile(temp_file.name, 'w') as zipf: - for note in viewable_notes: - # Get content based on format - safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) - - if format == 'md': - content = note.content - filename = f"{safe_filename}.md" - elif format == 'html': - content = f""" - - - - {note.title} - - - - - {note.render_html()} - -""" - filename = f"{safe_filename}.html" - else: # txt - metadata, body = parse_frontmatter(note.content) - # Remove markdown formatting - text_body = body - text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE) - text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body) - text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body) - text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body) - text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body) - text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL) - text_body = re.sub(r'`([^`]+)`', r'\1', text_body) - - content = f"{note.title}\n{'=' * len(note.title)}\n\n" - content += f"Author: {note.created_by.username}\n" - content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n" - content += f"Folder: {note.folder}\n\n" - content += "-" * 40 + "\n\n" - content += text_body - filename = f"{safe_filename}.txt" - - # Add file to zip - zipf.writestr(filename, content) - - # Send the zip file - temp_file.seek(0) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - safe_folder_name = re.sub(r'[^a-zA-Z0-9_-]', '_', folder_path.replace('/', '_')) - - return send_file( - temp_file.name, - mimetype='application/zip', - as_attachment=True, - download_name=f'{safe_folder_name}_notes_{timestamp}.zip' - ) - - finally: - # Clean up temp file after sending - os.unlink(temp_file.name) - - -@app.route('/notes//edit', methods=['GET', 'POST']) -@login_required -@company_required -def edit_note(slug): - """Edit an existing note""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_edit(g.user): - abort(403) - - if request.method == 'POST': - title = request.form.get('title', '').strip() - content = request.form.get('content', '').strip() - visibility = request.form.get('visibility', 'Private') - folder = request.form.get('folder', '').strip() - tags = request.form.get('tags', '').strip() - project_id = request.form.get('project_id') - task_id = request.form.get('task_id') - - # Validate - if not title: - flash('Title is required', 'error') - return redirect(url_for('edit_note', slug=slug)) - - if not content: - flash('Content is required', 'error') - return redirect(url_for('edit_note', slug=slug)) - - try: - # Parse tags - tag_list = [] - if tags: - tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()] - - # Update note - note.title = title - note.content = content - note.visibility = NoteVisibility[visibility.upper()] # Convert to uppercase for enum access - note.folder = folder if folder else None - note.tags = ','.join(tag_list) if tag_list else None - - # Sync metadata from frontmatter if present - note.sync_from_frontmatter() - - # Update team_id if visibility is Team - if visibility == 'Team' and g.user.team_id: - note.team_id = g.user.team_id - else: - note.team_id = None - - # Update optional associations - if project_id: - project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first() - note.project_id = project.id if project else None - else: - note.project_id = None - - if task_id: - task = Task.query.filter_by(id=task_id).first() - if task and task.project.company_id == g.user.company_id: - note.task_id = task.id - else: - note.task_id = None - else: - note.task_id = None - - # Regenerate slug if title changed - new_slug = note.generate_slug() - if new_slug != note.slug: - note.slug = new_slug - - db.session.commit() - - flash('Note updated successfully', 'success') - return redirect(url_for('view_note', slug=note.slug)) - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating note: {str(e)}") - flash('Error updating note', 'error') - return redirect(url_for('edit_note', slug=slug)) - - # GET request - show form - projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all() - tasks = [] - if note.project_id: - tasks = Task.query.filter_by(project_id=note.project_id).all() - - # Get all existing folders for suggestions - all_folders = set() - - # Get folders from NoteFolder table - folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all() - for folder in folder_records: - all_folders.add(folder.path) - - # Also get folders from notes (for backward compatibility) - folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all() - for n in folder_notes: - if n.folder: - all_folders.add(n.folder) - - return render_template('note_editor.html', - title=f'Edit: {note.title}', - note=note, - projects=projects, - tasks=tasks, - all_folders=sorted(list(all_folders)), - NoteVisibility=NoteVisibility) - - -@app.route('/notes//delete', methods=['POST']) -@login_required -@company_required -def delete_note(slug): - """Delete (archive) a note""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_edit(g.user): - abort(403) - - try: - # Soft delete - note.is_archived = True - note.archived_at = datetime.now() - db.session.commit() - - flash('Note deleted successfully', 'success') - return redirect(url_for('notes_list')) - - except Exception as e: - db.session.rollback() - logger.error(f"Error deleting note: {str(e)}") - flash('Error deleting note', 'error') - return redirect(url_for('view_note', slug=slug)) - - -@app.route('/notes/folders') -@login_required -@company_required -def notes_folders(): - """Manage note folders""" - # Get all folders from NoteFolder table - all_folders = set() - folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all() - - for folder in folder_records: - all_folders.add(folder.path) - - # Also get folders from notes (for backward compatibility) - folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all() - - folder_counts = {} - for note in folder_notes: - if note.folder and note.can_user_view(g.user): - # Add this folder and all parent folders - parts = note.folder.split('/') - for i in range(len(parts)): - folder_path = '/'.join(parts[:i+1]) - all_folders.add(folder_path) - folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0) - - # Initialize counts for empty folders - for folder_path in all_folders: - if folder_path not in folder_counts: - folder_counts[folder_path] = 0 - - # Build folder tree structure - folder_tree = {} - for folder in sorted(all_folders): - parts = folder.split('/') - current = folder_tree - - for i, part in enumerate(parts): - if i == len(parts) - 1: - # Leaf folder - current[folder] = {} - else: - # Navigate to parent - parent_path = '/'.join(parts[:i+1]) - if parent_path not in current: - current[parent_path] = {} - current = current[parent_path] - - return render_template('notes_folders.html', - title='Note Folders', - all_folders=sorted(list(all_folders)), - folder_tree=folder_tree, - folder_counts=folder_counts) - - -@app.route('/api/notes/folder-details') -@login_required -@company_required -def api_folder_details(): - """Get details about a specific folder""" - folder_path = request.args.get('path', '') - - if not folder_path: - return jsonify({'error': 'Folder path required'}), 400 - - # Get notes in this folder - notes = Note.query.filter_by( - company_id=g.user.company_id, - folder=folder_path, - is_archived=False - ).all() - - # Filter by visibility - visible_notes = [n for n in notes if n.can_user_view(g.user)] - - # Get subfolders - all_folders = set() - folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter( - Note.folder.like(f'{folder_path}/%') - ).all() - - for note in folder_notes: - if note.folder and note.can_user_view(g.user): - # Get immediate subfolder - subfolder = note.folder[len(folder_path)+1:] - if '/' in subfolder: - subfolder = subfolder.split('/')[0] - all_folders.add(subfolder) - - # Get recent notes (last 5) - recent_notes = sorted(visible_notes, key=lambda n: n.updated_at, reverse=True)[:5] - - return jsonify({ - 'name': folder_path.split('/')[-1], - 'path': folder_path, - 'note_count': len(visible_notes), - 'subfolder_count': len(all_folders), - 'recent_notes': [ - { - 'title': n.title, - 'slug': n.slug, - 'updated_at': n.updated_at.strftime('%Y-%m-%d %H:%M') - } for n in recent_notes - ] - }) - - -@app.route('/api/notes/folders', methods=['POST']) -@login_required -@company_required -def api_create_folder(): - """Create a new folder""" - data = request.get_json() - folder_name = data.get('name', '').strip() - parent_folder = data.get('parent', '').strip() - - if not folder_name: - return jsonify({'success': False, 'message': 'Folder name is required'}), 400 - - # Validate folder name (no special characters except dash and underscore) - if not re.match(r'^[a-zA-Z0-9_\- ]+$', folder_name): - return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400 - - # Create full path - full_path = f"{parent_folder}/{folder_name}" if parent_folder else folder_name - - # Check if folder already exists - existing_folder = NoteFolder.query.filter_by( - company_id=g.user.company_id, - path=full_path - ).first() - - if existing_folder: - return jsonify({'success': False, 'message': 'Folder already exists'}), 400 - - # Create the folder - try: - folder = NoteFolder( - name=folder_name, - path=full_path, - parent_path=parent_folder if parent_folder else None, - description=data.get('description', ''), - created_by_id=g.user.id, - company_id=g.user.company_id - ) - - db.session.add(folder) - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Folder created successfully', - 'folder': { - 'name': folder_name, - 'path': full_path - } - }) - except Exception as e: - db.session.rollback() - logger.error(f"Error creating folder: {str(e)}") - return jsonify({'success': False, 'message': 'Error creating folder'}), 500 - - -@app.route('/api/notes/folders', methods=['PUT']) -@login_required -@company_required -def api_rename_folder(): - """Rename an existing folder""" - data = request.get_json() - old_path = data.get('old_path', '').strip() - new_name = data.get('new_name', '').strip() - - if not old_path or not new_name: - return jsonify({'success': False, 'message': 'Old path and new name are required'}), 400 - - # Validate folder name - if not re.match(r'^[a-zA-Z0-9_\- ]+$', new_name): - return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400 - - # Build new path - path_parts = old_path.split('/') - path_parts[-1] = new_name - new_path = '/'.join(path_parts) - - # Update all notes in this folder and subfolders - notes_to_update = Note.query.filter( - Note.company_id == g.user.company_id, - db.or_( - Note.folder == old_path, - Note.folder.like(f'{old_path}/%') - ) - ).all() - - # Check permissions for all notes - for note in notes_to_update: - if not note.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'You do not have permission to modify all notes in this folder'}), 403 - - # Update folder paths - try: - for note in notes_to_update: - if note.folder == old_path: - note.folder = new_path - else: - # Update subfolder path - note.folder = new_path + note.folder[len(old_path):] - - db.session.commit() - - return jsonify({ - 'success': True, - 'message': f'Renamed folder to {new_name}', - 'updated_count': len(notes_to_update) - }) - except Exception as e: - db.session.rollback() - logger.error(f"Error renaming folder: {str(e)}") - return jsonify({'success': False, 'message': 'Error renaming folder'}), 500 - - -@app.route('/api/notes/folders', methods=['DELETE']) -@login_required -@company_required -def api_delete_folder(): - """Delete an empty folder""" - folder_path = request.args.get('path', '').strip() - - if not folder_path: - return jsonify({'success': False, 'message': 'Folder path is required'}), 400 - - # Check if folder has any notes - notes_in_folder = Note.query.filter_by( - company_id=g.user.company_id, - folder=folder_path, - is_archived=False - ).all() - - if notes_in_folder: - return jsonify({'success': False, 'message': 'Cannot delete folder that contains notes'}), 400 - - # Check if folder has subfolders with notes - notes_in_subfolders = Note.query.filter( - Note.company_id == g.user.company_id, - Note.folder.like(f'{folder_path}/%'), - Note.is_archived == False - ).first() - - if notes_in_subfolders: - return jsonify({'success': False, 'message': 'Cannot delete folder that contains subfolders with notes'}), 400 - - # Since we don't have a separate folders table, we just return success - # The folder will disappear from the UI when there are no notes in it - - return jsonify({ - 'success': True, - 'message': 'Folder deleted successfully' - }) - - -@app.route('/api/notes//folder', methods=['PUT']) -@login_required -@company_required -def update_note_folder(slug): - """Update a note's folder via drag and drop""" - note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'Permission denied'}), 403 - - data = request.get_json() - folder_path = data.get('folder', '').strip() - - try: - # Update the note's folder - note.folder = folder_path if folder_path else None - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Note moved successfully', - 'folder': folder_path - }) - except Exception as e: - db.session.rollback() - logger.error(f"Error updating note folder: {str(e)}") - return jsonify({'success': False, 'message': 'Error updating note folder'}), 500 - - -@app.route('/api/notes//tags', methods=['POST']) -@login_required -@company_required -def add_tags_to_note(note_id): - """Add tags to a note""" - note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not note.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'Permission denied'}), 403 - - data = request.get_json() - new_tags = data.get('tags', '').strip() - - if not new_tags: - return jsonify({'success': False, 'message': 'No tags provided'}), 400 - - try: - # Get existing tags - existing_tags = note.get_tags_list() - - # Parse new tags - new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()] - - # Merge tags (avoid duplicates) - all_tags = list(set(existing_tags + new_tag_list)) - - # Update note - note.set_tags_list(all_tags) - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Tags added successfully', - 'tags': note.tags - }) - except Exception as e: - db.session.rollback() - logger.error(f"Error adding tags to note: {str(e)}") - return jsonify({'success': False, 'message': 'Error adding tags'}), 500 - - -@app.route('/api/notes//link', methods=['POST']) -@login_required -@company_required -def link_notes(note_id): - """Create a link between two notes""" - source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404() - - # Check permissions - if not source_note.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'Permission denied'}), 403 - - data = request.get_json() - target_note_id = data.get('target_note_id') - link_type = data.get('link_type', 'related') - - if not target_note_id: - return jsonify({'success': False, 'message': 'Target note ID required'}), 400 - - target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first() - if not target_note: - return jsonify({'success': False, 'message': 'Target note not found'}), 404 - - if not target_note.can_user_view(g.user): - return jsonify({'success': False, 'message': 'Cannot link to this note'}), 403 - - try: - # Check if link already exists (in either direction) - existing_link = NoteLink.query.filter( - db.or_( - db.and_( - NoteLink.source_note_id == note_id, - NoteLink.target_note_id == target_note_id - ), - db.and_( - NoteLink.source_note_id == target_note_id, - NoteLink.target_note_id == note_id - ) - ) - ).first() - - if existing_link: - return jsonify({'success': False, 'message': 'Link already exists between these notes'}), 400 - - # Create link - link = NoteLink( - source_note_id=note_id, - target_note_id=target_note_id, - link_type=link_type, - created_by_id=g.user.id - ) - - db.session.add(link) - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Notes linked successfully', - 'link': { - 'id': link.id, - 'source_note_id': link.source_note_id, - 'target_note_id': link.target_note_id, - 'target_note': { - 'id': target_note.id, - 'title': target_note.title, - 'slug': target_note.slug - } - } - }) - - except Exception as e: - db.session.rollback() - logger.error(f"Error linking notes: {str(e)}") - return jsonify({'success': False, 'message': f'Error linking notes: {str(e)}'}), 500 - -# We can keep this route as a redirect to home for backward compatibility @app.route('/timetrack') @login_required def timetrack(): @@ -4991,7 +3870,6 @@ def manage_project_tasks(project_id): team_members=team_members) - # Unified Task Management Route @app.route('/tasks') @role_required(Role.TEAM_MEMBER) @@ -7073,50 +5951,6 @@ def render_markdown(): return jsonify({'html': '

Error rendering markdown

'}) # Note link deletion endpoint -@app.route('/api/notes//link', methods=['DELETE']) -@login_required -@company_required -def unlink_notes(note_id): - """Remove a link between two notes""" - try: - note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() - - if not note: - return jsonify({'success': False, 'message': 'Note not found'}) - - if not note.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'Permission denied'}) - - data = request.get_json() - target_note_id = data.get('target_note_id') - - if not target_note_id: - return jsonify({'success': False, 'message': 'Target note ID required'}) - - # Find and remove the link - link = NoteLink.query.filter_by( - source_note_id=note_id, - target_note_id=target_note_id - ).first() - - if not link: - # Try reverse direction - link = NoteLink.query.filter_by( - source_note_id=target_note_id, - target_note_id=note_id - ).first() - - if link: - db.session.delete(link) - db.session.commit() - return jsonify({'success': True, 'message': 'Link removed successfully'}) - else: - return jsonify({'success': False, 'message': 'Link not found'}) - - except Exception as e: - db.session.rollback() - logger.error(f"Error removing note link: {str(e)}") - return jsonify({'success': False, 'message': str(e)}) if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..8ca12ba --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1 @@ +# Routes package initialization \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..a7b09e9 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,76 @@ +# Standard library imports +from functools import wraps + +# Third-party imports +from flask import flash, g, redirect, request, url_for + +# Local application imports +from models import Company, Role, User + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + + +def company_required(f): + """ + Decorator to ensure user has a valid company association and set company context. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + + # System admins can access without company association + if g.user.role == Role.SYSTEM_ADMIN: + return f(*args, **kwargs) + + if g.user.company_id is None: + flash('You must be associated with a company to access this page.', 'error') + return redirect(url_for('setup_company')) + + # Set company context + g.company = Company.query.get(g.user.company_id) + if not g.company or not g.company.is_active: + flash('Your company account is inactive.', 'error') + return redirect(url_for('home')) + + return f(*args, **kwargs) + return decorated_function + + +def role_required(*allowed_roles): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user.role not in allowed_roles: + flash('You do not have permission to access this page.', 'error') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + return decorator + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]: + flash('Admin access required.', 'error') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + + +def system_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user.role != Role.SYSTEM_ADMIN: + flash('System admin access required.', 'error') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/routes/notes.py b/routes/notes.py new file mode 100644 index 0000000..b4e7e3e --- /dev/null +++ b/routes/notes.py @@ -0,0 +1,491 @@ +# Standard library imports +from datetime import datetime, timezone + +# Third-party imports +from flask import (Blueprint, abort, flash, g, jsonify, redirect, + render_template, request, url_for) +from sqlalchemy import and_, or_ + +# Local application imports +from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project, + Task, db) +from routes.auth import company_required, login_required + +# Create blueprint +notes_bp = Blueprint('notes', __name__, url_prefix='/notes') + + +@notes_bp.route('') +@login_required +@company_required +def notes_list(): + """List all notes with optional filtering""" + import logging + logger = logging.getLogger(__name__) + logger.info("Notes list route called") + + # Get filter parameters + folder_filter = request.args.get('folder', '') + tag_filter = request.args.get('tag', '') + visibility_filter = request.args.get('visibility', '') + search_query = request.args.get('search', '') + + # Base query - only non-archived notes for the user's company + query = Note.query.filter_by( + company_id=g.user.company_id, + is_archived=False + ) + + # Apply folder filter + if folder_filter: + query = query.filter_by(folder=folder_filter) + + # Apply tag filter + if tag_filter: + query = query.filter(Note.tags.contains(tag_filter)) + + # Apply visibility filter + if visibility_filter: + if visibility_filter == 'private': + query = query.filter_by(visibility=NoteVisibility.PRIVATE, created_by_id=g.user.id) + elif visibility_filter == 'team': + query = query.filter( + and_( + Note.visibility == NoteVisibility.TEAM, + or_( + Note.created_by.has(team_id=g.user.team_id), + Note.created_by_id == g.user.id + ) + ) + ) + elif visibility_filter == 'company': + query = query.filter_by(visibility=NoteVisibility.COMPANY) + else: + # Default visibility filtering - show notes user can see + query = query.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 search_query: + search_pattern = f'%{search_query}%' + query = query.filter( + or_( + Note.title.ilike(search_pattern), + Note.content.ilike(search_pattern), + Note.tags.ilike(search_pattern) + ) + ) + + # Order by pinned first, then by updated date + notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all() + + # Get all folders for the sidebar + all_folders = NoteFolder.query.filter_by( + company_id=g.user.company_id + ).order_by(NoteFolder.path).all() + + # Build folder tree structure + folder_tree = {} + folder_counts = {} + + # Count notes per folder + folder_note_counts = db.session.query( + Note.folder, db.func.count(Note.id) + ).filter_by( + company_id=g.user.company_id, + is_archived=False + ).group_by(Note.folder).all() + + for folder, count in folder_note_counts: + if folder: + folder_counts[folder] = count + + # Build folder tree structure + for folder in all_folders: + parts = folder.path.split('/') + current = folder_tree + + for i, part in enumerate(parts): + if i == len(parts) - 1: + # Leaf folder - use full path as key + current[folder.path] = {} + else: + # Navigate to parent using full path + parent_path = '/'.join(parts[:i+1]) + if parent_path not in current: + current[parent_path] = {} + current = current[parent_path] + + # Get all unique tags + all_notes_for_tags = Note.query.filter_by( + company_id=g.user.company_id, + is_archived=False + ).all() + + all_tags = set() + tag_counts = {} + + for note in all_notes_for_tags: + if note.tags: + note_tags = note.get_tags_list() + for tag in note_tags: + all_tags.add(tag) + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + all_tags = sorted(list(all_tags)) + + # Count notes by visibility + visibility_counts = { + 'private': Note.query.filter_by( + company_id=g.user.company_id, + visibility=NoteVisibility.PRIVATE, + created_by_id=g.user.id, + is_archived=False + ).count(), + 'team': Note.query.filter( + Note.company_id == g.user.company_id, + Note.visibility == NoteVisibility.TEAM, + Note.created_by.has(team_id=g.user.team_id), + Note.is_archived == False + ).count(), + 'company': Note.query.filter_by( + company_id=g.user.company_id, + visibility=NoteVisibility.COMPANY, + is_archived=False + ).count() + } + + try: + logger.info(f"Rendering template with {len(notes)} notes, folder_tree type: {type(folder_tree)}") + return render_template('notes_list.html', + notes=notes, + folder_tree=folder_tree, + folder_counts=folder_counts, + all_tags=all_tags, + tag_counts=tag_counts, + visibility_counts=visibility_counts, + folder_filter=folder_filter, + tag_filter=tag_filter, + visibility_filter=visibility_filter, + search_query=search_query, + title='Notes') + except Exception as e: + logger.error(f"Error rendering notes template: {str(e)}", exc_info=True) + raise + + +@notes_bp.route('/new', methods=['GET', 'POST']) +@login_required +@company_required +def create_note(): + """Create a new note""" + if request.method == 'POST': + title = request.form.get('title', '').strip() + content = request.form.get('content', '').strip() + visibility = request.form.get('visibility', 'Private') + folder = request.form.get('folder', '').strip() + tags = request.form.get('tags', '').strip() + project_id = request.form.get('project_id') + task_id = request.form.get('task_id') + + # Validate + if not title: + flash('Title is required', 'error') + return redirect(url_for('notes.create_note')) + + if not content: + flash('Content is required', 'error') + return redirect(url_for('notes.create_note')) + + # Validate visibility + try: + visibility_enum = NoteVisibility[visibility.upper()] + except KeyError: + visibility_enum = NoteVisibility.PRIVATE + + # Validate project if provided + project = None + if project_id: + project = Project.query.filter_by( + id=project_id, + company_id=g.user.company_id + ).first() + if not project: + flash('Invalid project selected', 'error') + return redirect(url_for('notes.create_note')) + + # Validate task if provided + task = None + if task_id: + task = Task.query.filter_by(id=task_id).first() + if not task or (project and task.project_id != project.id): + flash('Invalid task selected', 'error') + return redirect(url_for('notes.create_note')) + + # Create note + note = Note( + title=title, + content=content, + visibility=visibility_enum, + folder=folder if folder else None, + tags=tags if tags else None, + company_id=g.user.company_id, + created_by_id=g.user.id, + project_id=project.id if project else None, + task_id=task.id if task else None + ) + + db.session.add(note) + db.session.commit() + + flash('Note created successfully', 'success') + return redirect(url_for('notes.view_note', slug=note.slug)) + + # GET request - show form + # Get folders for dropdown + folders = NoteFolder.query.filter_by( + company_id=g.user.company_id + ).order_by(NoteFolder.path).all() + + # Get projects for dropdown + projects = Project.query.filter_by( + company_id=g.user.company_id, + is_archived=False + ).order_by(Project.name).all() + + # Get task if specified in URL + task_id = request.args.get('task_id') + task = None + if task_id: + task = Task.query.filter_by(id=task_id).first() + + return render_template('note_editor.html', + folders=folders, + projects=projects, + task=task, + title='Create Note') + + +@notes_bp.route('/') +@login_required +@company_required +def view_note(slug): + """View a note""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_view(g.user): + abort(403) + + # Get linked notes + outgoing_links = NoteLink.query.filter_by( + source_note_id=note.id + ).join( + Note, NoteLink.target_note_id == Note.id + ).filter( + Note.is_archived == False + ).all() + + incoming_links = NoteLink.query.filter_by( + target_note_id=note.id + ).join( + Note, NoteLink.source_note_id == Note.id + ).filter( + Note.is_archived == False + ).all() + + # Get linkable notes for the modal + linkable_notes = Note.query.filter( + Note.company_id == g.user.company_id, + Note.id != note.id, + Note.is_archived == False + ).filter( + or_( + # User's private notes + and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id), + # Team notes + and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)), + # Company notes + Note.visibility == NoteVisibility.COMPANY + ) + ).order_by(Note.title).all() + + return render_template('note_view.html', + note=note, + outgoing_links=outgoing_links, + incoming_links=incoming_links, + linkable_notes=linkable_notes, + title=note.title) + + +@notes_bp.route('//mindmap') +@login_required +@company_required +def view_note_mindmap(slug): + """View a note as a mind map""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_view(g.user): + abort(403) + + return render_template('note_mindmap.html', note=note, title=f"{note.title} - Mind Map") + + +@notes_bp.route('//edit', methods=['GET', 'POST']) +@login_required +@company_required +def edit_note(slug): + """Edit an existing note""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_edit(g.user): + abort(403) + + if request.method == 'POST': + title = request.form.get('title', '').strip() + content = request.form.get('content', '').strip() + visibility = request.form.get('visibility', 'Private') + folder = request.form.get('folder', '').strip() + tags = request.form.get('tags', '').strip() + project_id = request.form.get('project_id') + task_id = request.form.get('task_id') + + # Validate + if not title: + flash('Title is required', 'error') + return redirect(url_for('notes.edit_note', slug=slug)) + + if not content: + flash('Content is required', 'error') + return redirect(url_for('notes.edit_note', slug=slug)) + + # Validate visibility + try: + visibility_enum = NoteVisibility[visibility.upper()] + except KeyError: + visibility_enum = NoteVisibility.PRIVATE + + # Validate project if provided + project = None + if project_id: + project = Project.query.filter_by( + id=project_id, + company_id=g.user.company_id + ).first() + if not project: + flash('Invalid project selected', 'error') + return redirect(url_for('notes.edit_note', slug=slug)) + + # Validate task if provided + task = None + if task_id: + task = Task.query.filter_by(id=task_id).first() + if not task or (project and task.project_id != project.id): + flash('Invalid task selected', 'error') + return redirect(url_for('notes.edit_note', slug=slug)) + + # Update note + note.title = title + note.content = content + note.visibility = visibility_enum + note.folder = folder if folder else None + note.tags = tags if tags else None + note.project_id = project.id if project else None + note.task_id = task.id if task else None + note.updated_at = datetime.now(timezone.utc) + + # Update slug if title changed + note.generate_slug() + + db.session.commit() + + flash('Note updated successfully', 'success') + return redirect(url_for('notes.view_note', slug=note.slug)) + + # GET request - show form + # Get folders for dropdown + folders = NoteFolder.query.filter_by( + company_id=g.user.company_id + ).order_by(NoteFolder.path).all() + + # Get projects for dropdown + projects = Project.query.filter_by( + company_id=g.user.company_id, + is_archived=False + ).order_by(Project.name).all() + + return render_template('note_editor.html', + note=note, + folders=folders, + projects=projects, + title=f'Edit {note.title}') + + +@notes_bp.route('//delete', methods=['POST']) +@login_required +@company_required +def delete_note(slug): + """Delete (archive) a note""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_edit(g.user): + abort(403) + + # Archive the note + note.is_archived = True + note.updated_at = datetime.utcnow() + + db.session.commit() + + flash('Note deleted successfully', 'success') + return redirect(url_for('notes.notes_list')) + + +@notes_bp.route('/folders') +@login_required +@company_required +def notes_folders(): + """Manage note folders""" + # Get all folders + folders = NoteFolder.query.filter_by( + company_id=g.user.company_id + ).order_by(NoteFolder.path).all() + + # Get note counts per folder + folder_counts = {} + folder_note_counts = db.session.query( + Note.folder, db.func.count(Note.id) + ).filter_by( + company_id=g.user.company_id, + is_archived=False + ).filter(Note.folder.isnot(None)).group_by(Note.folder).all() + + for folder, count in folder_note_counts: + folder_counts[folder] = count + + # Build folder tree + folder_tree = {} + for folder in folders: + parts = folder.path.split('/') + current = folder_tree + for part in parts: + if part not in current: + current[part] = {} + current = current[part] + + return render_template('notes_folders.html', + folders=folders, + folder_tree=folder_tree, + folder_counts=folder_counts, + title='Manage Folders') \ No newline at end of file diff --git a/routes/notes_api.py b/routes/notes_api.py new file mode 100644 index 0000000..a487ec7 --- /dev/null +++ b/routes/notes_api.py @@ -0,0 +1,388 @@ +# Standard library imports +from datetime import datetime, timezone + +# Third-party imports +from flask import Blueprint, abort, g, jsonify, request +from sqlalchemy import and_, or_ + +# Local application imports +from models import Note, NoteFolder, NoteLink, NoteVisibility, db +from routes.auth import company_required, login_required + +# Create blueprint +notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes') + + +@notes_api_bp.route('/folder-details') +@login_required +@company_required +def api_folder_details(): + """Get folder details including note count""" + folder_path = request.args.get('folder', '') + + # Get note count for this folder + note_count = Note.query.filter_by( + company_id=g.user.company_id, + folder=folder_path, + is_archived=False + ).count() + + # Check if folder exists in NoteFolder table + folder = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=folder_path + ).first() + + return jsonify({ + 'success': True, + 'folder': { + 'path': folder_path, + 'name': folder_path.split('/')[-1] if folder_path else 'Root', + 'exists': folder is not None, + 'note_count': note_count, + 'created_at': folder.created_at.isoformat() if folder else None + } + }) + + +@notes_api_bp.route('/folders', methods=['POST']) +@login_required +@company_required +def api_create_folder(): + """Create a new folder""" + data = request.get_json() + if not data: + return jsonify({'success': False, 'message': 'No data provided'}), 400 + + folder_name = data.get('name', '').strip() + parent_path = data.get('parent', '').strip() + + if not folder_name: + return jsonify({'success': False, 'message': 'Folder name is required'}), 400 + + # Validate folder name + if '/' in folder_name: + return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400 + + # Build full path + if parent_path: + full_path = f"{parent_path}/{folder_name}" + else: + full_path = folder_name + + # Check if folder already exists + existing = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=full_path + ).first() + + if existing: + return jsonify({'success': False, 'message': 'Folder already exists'}), 400 + + # Create folder + folder = NoteFolder( + path=full_path, + company_id=g.user.company_id, + created_by_id=g.user.id + ) + + db.session.add(folder) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Folder created successfully', + 'folder': { + 'path': folder.path, + 'name': folder_name + } + }) + + +@notes_api_bp.route('/folders', methods=['PUT']) +@login_required +@company_required +def api_rename_folder(): + """Rename a folder""" + data = request.get_json() + if not data: + return jsonify({'success': False, 'message': 'No data provided'}), 400 + + old_path = data.get('old_path', '').strip() + new_name = data.get('new_name', '').strip() + + if not old_path or not new_name: + return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400 + + # Validate new name + if '/' in new_name: + return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400 + + # Find the folder + folder = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=old_path + ).first() + + if not folder: + return jsonify({'success': False, 'message': 'Folder not found'}), 404 + + # Build new path + path_parts = old_path.split('/') + path_parts[-1] = new_name + new_path = '/'.join(path_parts) + + # Check if new path already exists + existing = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=new_path + ).first() + + if existing: + return jsonify({'success': False, 'message': 'A folder with this name already exists'}), 400 + + # Update folder path + folder.path = new_path + + # Update all notes in this folder + Note.query.filter_by( + company_id=g.user.company_id, + folder=old_path + ).update({Note.folder: new_path}) + + # Update all subfolders + subfolders = NoteFolder.query.filter( + NoteFolder.company_id == g.user.company_id, + NoteFolder.path.like(f"{old_path}/%") + ).all() + + for subfolder in subfolders: + subfolder.path = subfolder.path.replace(old_path, new_path, 1) + + # Update all notes in subfolders + notes_in_subfolders = Note.query.filter( + Note.company_id == g.user.company_id, + Note.folder.like(f"{old_path}/%") + ).all() + + for note in notes_in_subfolders: + note.folder = note.folder.replace(old_path, new_path, 1) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Folder renamed successfully', + 'folder': { + 'old_path': old_path, + 'new_path': new_path + } + }) + + +@notes_api_bp.route('/folders', methods=['DELETE']) +@login_required +@company_required +def api_delete_folder(): + """Delete a folder""" + folder_path = request.args.get('path', '').strip() + + if not folder_path: + return jsonify({'success': False, 'message': 'Folder path is required'}), 400 + + # Check if folder has notes + note_count = Note.query.filter_by( + company_id=g.user.company_id, + folder=folder_path, + is_archived=False + ).count() + + if note_count > 0: + return jsonify({ + 'success': False, + 'message': f'Cannot delete folder with {note_count} notes. Please move or delete the notes first.' + }), 400 + + # Find and delete the folder + folder = NoteFolder.query.filter_by( + company_id=g.user.company_id, + path=folder_path + ).first() + + if folder: + db.session.delete(folder) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Folder deleted successfully' + }) + + +@notes_api_bp.route('//folder', methods=['PUT']) +@login_required +@company_required +def update_note_folder(slug): + """Update a note's folder via drag and drop""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + data = request.get_json() + new_folder = data.get('folder', '').strip() + + # Update note folder + note.folder = new_folder if new_folder else None + note.updated_at = datetime.now(timezone.utc) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Note moved successfully' + }) + + +@notes_api_bp.route('//tags', methods=['POST']) +@login_required +@company_required +def add_tags_to_note(note_id): + """Add tags to a note""" + note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() + + if not note: + return jsonify({'success': False, 'message': 'Note not found'}), 404 + + # Check permissions + if not note.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + data = request.get_json() + new_tags = data.get('tags', '').strip() + + if not new_tags: + return jsonify({'success': False, 'message': 'No tags provided'}), 400 + + # Merge with existing tags + existing_tags = note.get_tags_list() + new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()] + + # Combine and deduplicate + all_tags = list(set(existing_tags + new_tag_list)) + + # Update note + note.tags = ', '.join(sorted(all_tags)) + note.updated_at = datetime.now(timezone.utc) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Tags added successfully', + 'tags': all_tags + }) + + +@notes_api_bp.route('//link', methods=['POST']) +@login_required +@company_required +def link_notes(note_id): + """Create a link between two notes""" + source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() + + if not source_note: + return jsonify({'success': False, 'message': 'Source note not found'}), 404 + + # Check permissions + if not source_note.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + data = request.get_json() + target_note_id = data.get('target_note_id') + link_type = data.get('link_type', 'related') + + if not target_note_id: + return jsonify({'success': False, 'message': 'Target note ID is required'}), 400 + + # Get target note + target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first() + + if not target_note: + return jsonify({'success': False, 'message': 'Target note not found'}), 404 + + # Check if user can view target note + if not target_note.can_user_view(g.user): + return jsonify({'success': False, 'message': 'You cannot link to a note you cannot view'}), 403 + + # Check if link already exists + existing_link = NoteLink.query.filter_by( + source_note_id=source_note.id, + target_note_id=target_note.id + ).first() + + if existing_link: + return jsonify({'success': False, 'message': 'Link already exists'}), 400 + + # Create link + link = NoteLink( + source_note_id=source_note.id, + target_note_id=target_note.id, + link_type=link_type, + created_by_id=g.user.id + ) + + db.session.add(link) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Notes linked successfully' + }) + + +@notes_api_bp.route('//link', methods=['DELETE']) +@login_required +@company_required +def unlink_notes(note_id): + """Remove a link between two notes""" + source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() + + if not source_note: + return jsonify({'success': False, 'message': 'Source note not found'}), 404 + + # Check permissions + if not source_note.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + data = request.get_json() + target_note_id = data.get('target_note_id') + + if not target_note_id: + return jsonify({'success': False, 'message': 'Target note ID is required'}), 400 + + # Find and delete the link (check both directions) + link = NoteLink.query.filter( + or_( + and_( + NoteLink.source_note_id == source_note.id, + NoteLink.target_note_id == target_note_id + ), + and_( + NoteLink.source_note_id == target_note_id, + NoteLink.target_note_id == source_note.id + ) + ) + ).first() + + if not link: + return jsonify({'success': False, 'message': 'Link not found'}), 404 + + db.session.delete(link) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Link removed successfully' + }) \ No newline at end of file diff --git a/routes/notes_download.py b/routes/notes_download.py new file mode 100644 index 0000000..ac35e46 --- /dev/null +++ b/routes/notes_download.py @@ -0,0 +1,288 @@ +# Standard library imports +import os +import re +import tempfile +import zipfile +from datetime import datetime +from urllib.parse import unquote + +# Third-party imports +from flask import (Blueprint, Response, abort, flash, g, redirect, request, + send_file, url_for) + +# Local application imports +from frontmatter_utils import parse_frontmatter +from models import Note, db +from routes.auth import company_required, login_required + +# Create blueprint +notes_download_bp = Blueprint('notes_download', __name__) + + +@notes_download_bp.route('/notes//download/') +@login_required +@company_required +def download_note(slug, format): + """Download a note in various formats""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_view(g.user): + abort(403) + + # Prepare filename + safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) + timestamp = datetime.now().strftime('%Y%m%d') + + if format == 'md': + # Download as Markdown with frontmatter + content = note.content + response = Response(content, mimetype='text/markdown') + response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.md"' + return response + + elif format == 'html': + # Download as HTML + html_content = f""" + + + + {note.title} + + + + + {note.render_html()} + +""" + response = Response(html_content, mimetype='text/html') + response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.html"' + return response + + elif format == 'txt': + # Download as plain text + metadata, body = parse_frontmatter(note.content) + + # Create plain text version + text_content = f"{note.title}\n{'=' * len(note.title)}\n\n" + text_content += f"Author: {note.created_by.username}\n" + text_content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n" + text_content += f"Updated: {note.updated_at.strftime('%Y-%m-%d %H:%M')}\n" + text_content += f"Visibility: {note.visibility.value}\n" + if note.folder: + text_content += f"Folder: {note.folder}\n" + if note.tags: + text_content += f"Tags: {note.tags}\n" + text_content += "\n" + "-" * 40 + "\n\n" + + # Remove markdown formatting + text_body = body + # Remove headers markdown + text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE) + # Remove emphasis + text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body) + text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body) + # Remove links but keep text + text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body) + # Remove images + text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body) + # Remove code blocks markers + text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL) + text_body = re.sub(r'`([^`]+)`', r'\1', text_body) + + text_content += text_body + + response = Response(text_content, mimetype='text/plain') + response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.txt"' + return response + + else: + abort(404) + + +@notes_download_bp.route('/notes/download-bulk', methods=['POST']) +@login_required +@company_required +def download_notes_bulk(): + """Download multiple notes as a zip file""" + note_ids = request.form.getlist('note_ids[]') + format = request.form.get('format', 'md') + + if not note_ids: + flash('No notes selected for download', 'error') + return redirect(url_for('notes.notes_list')) + + # Create a temporary file for the zip + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') + + try: + with zipfile.ZipFile(temp_file.name, 'w') as zipf: + for note_id in note_ids: + note = Note.query.filter_by(id=int(note_id), company_id=g.user.company_id).first() + if note and note.can_user_view(g.user): + # Get content based on format + safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) + + if format == 'md': + content = note.content + filename = f"{safe_filename}.md" + elif format == 'html': + content = f""" + + + + {note.title} + + + +

{note.title}

+ {note.render_html()} + +""" + filename = f"{safe_filename}.html" + else: # txt + metadata, body = parse_frontmatter(note.content) + content = f"{note.title}\n{'=' * len(note.title)}\n\n{body}" + filename = f"{safe_filename}.txt" + + # Add file to zip + zipf.writestr(filename, content) + + # Send the zip file + temp_file.seek(0) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + + return send_file( + temp_file.name, + mimetype='application/zip', + as_attachment=True, + download_name=f'notes_{timestamp}.zip' + ) + + finally: + # Clean up temp file after sending + os.unlink(temp_file.name) + + +@notes_download_bp.route('/notes/folder//download/') +@login_required +@company_required +def download_folder(folder_path, format): + """Download all notes in a folder as a zip file""" + # Decode folder path (replace URL encoding) + folder_path = unquote(folder_path) + + # Get all notes in this folder + notes = Note.query.filter_by( + company_id=g.user.company_id, + folder=folder_path, + is_archived=False + ).all() + + # Filter notes user can view + viewable_notes = [note for note in notes if note.can_user_view(g.user)] + + if not viewable_notes: + flash('No notes found in this folder or you don\'t have permission to view them.', 'warning') + return redirect(url_for('notes.notes_list')) + + # Create a temporary file for the zip + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') + + try: + with zipfile.ZipFile(temp_file.name, 'w') as zipf: + for note in viewable_notes: + # Get content based on format + safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title) + + if format == 'md': + content = note.content + filename = f"{safe_filename}.md" + elif format == 'html': + content = f""" + + + + {note.title} + + + + + {note.render_html()} + +""" + filename = f"{safe_filename}.html" + else: # txt + metadata, body = parse_frontmatter(note.content) + # Remove markdown formatting + text_body = body + text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE) + text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body) + text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body) + text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body) + text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body) + text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL) + text_body = re.sub(r'`([^`]+)`', r'\1', text_body) + + content = f"{note.title}\n{'=' * len(note.title)}\n\n" + content += f"Author: {note.created_by.username}\n" + content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n" + content += f"Folder: {note.folder}\n\n" + content += "-" * 40 + "\n\n" + content += text_body + filename = f"{safe_filename}.txt" + + # Add file to zip + zipf.writestr(filename, content) + + # Send the zip file + temp_file.seek(0) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_folder_name = re.sub(r'[^a-zA-Z0-9_-]', '_', folder_path.replace('/', '_')) + + return send_file( + temp_file.name, + mimetype='application/zip', + as_attachment=True, + download_name=f'{safe_folder_name}_notes_{timestamp}.zip' + ) + + finally: + # Clean up temp file after sending + os.unlink(temp_file.name) \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index e6d13d5..f55645e 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -132,7 +132,7 @@
  • 📊Dashboard
  • 📋Task Management
  • 🏃‍♂️Sprints
  • -
  • 📝Notes
  • +
  • 📝Notes
  • 📊Analytics
  • diff --git a/templates/note_editor.html b/templates/note_editor.html index 6b02cec..ab41aa6 100644 --- a/templates/note_editor.html +++ b/templates/note_editor.html @@ -145,7 +145,7 @@ - Cancel + Cancel @@ -170,7 +170,7 @@
    {% for link in note.linked_notes %}
    - + {{ link.target_note.title }} {{ link.link_type }} diff --git a/templates/note_mindmap.html b/templates/note_mindmap.html index 7ffbc3d..b8a2863 100644 --- a/templates/note_mindmap.html +++ b/templates/note_mindmap.html @@ -11,7 +11,7 @@ - + Back to Note
    diff --git a/templates/note_view.html b/templates/note_view.html index 040e44d..3be020a 100644 --- a/templates/note_view.html +++ b/templates/note_view.html @@ -31,21 +31,21 @@ Download
    - + @@ -66,13 +66,13 @@ Mind Map {% if note.can_user_edit(g.user) %} - Edit -
    Edit +
    {% endif %} - Back to Notes + Back to Notes @@ -104,7 +104,7 @@ Tags: {% for tag in note.get_tags_list() %} - {{ tag }} + {{ tag }} {% endfor %} @@ -129,7 +129,7 @@ {% for link in outgoing_links %}
    @@ -143,7 +143,7 @@ {% for link in incoming_links %}
    diff --git a/templates/notes_folders.html b/templates/notes_folders.html index 187c63b..fdb379e 100644 --- a/templates/notes_folders.html +++ b/templates/notes_folders.html @@ -8,7 +8,7 @@ - Back to Notes + Back to Notes
    diff --git a/templates/notes_list.html b/templates/notes_list.html index 379fcba..a25368f 100644 --- a/templates/notes_list.html +++ b/templates/notes_list.html @@ -8,7 +8,7 @@ - + ⚙️ Manage Folders
    @@ -18,7 +18,7 @@
    - + @@ -117,7 +117,7 @@
    -
    + @@ -202,7 +202,7 @@ {% endif %} - + {{ note.title }}
    @@ -234,7 +234,7 @@ {% if note.tags %} {% for tag in note.get_tags_list() %} - {{ tag }} + {{ tag }} {% endfor %} {% endif %} @@ -243,12 +243,12 @@
    - + - + @@ -258,19 +258,19 @@ - + {% if note.can_user_edit(g.user) %} - + -