from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file from flask_login import current_user, login_required from app import db from app.models.document import Document, Category, Tag from app.models.user import User import json from datetime import datetime import io from sqlalchemy import or_ import re main = Blueprint('main', __name__) # Helper function to convert categories to a hierarchical dictionary def build_category_hierarchy(categories): categories_dict = [] # Start with root categories (parent_id is None) root_categories = [c for c in categories if c.parent_id is None] # Helper function to convert category and its children to dict def category_to_dict(category): cat_dict = { 'id': category.id, 'name': category.name, 'icon': category.icon, 'is_root': getattr(category, 'is_root', False), 'parent_id': category.parent_id, 'children': [] } # Find and add children for child in [c for c in categories if c.parent_id == category.id]: cat_dict['children'].append(category_to_dict(child)) return cat_dict # Convert all root categories and their children for category in root_categories: categories_dict.append(category_to_dict(category)) return categories_dict @main.route('/') @login_required def index(): """Home page showing categories and recent documents""" root_categories = Category.query.filter_by( user_id=current_user.id, parent_id=None ).all() recent_docs = Document.query.filter_by( user_id=current_user.id ).order_by(Document.updated_date.desc()).limit(5).all() return render_template('index.html', categories=root_categories, recent_docs=recent_docs) @main.route('/category/') @login_required def view_category(category_id): """View a specific category and its documents""" category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404() return render_template('category.html', category=category) @main.route('/category//all_documents') @login_required def category_all_documents(category_id): """View all documents in a category, including those in subcategories""" category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404() # Get all documents from this category documents = [] # Add documents from the current category for doc in category.documents: documents.append(doc) # Helper function to recursively collect documents from subcategories def collect_documents(cat): docs = [] for doc in cat.documents: docs.append(doc) for child in cat.children: docs.extend(collect_documents(child)) return docs # Collect documents from all subcategories for subcategory in category.children: documents.extend(collect_documents(subcategory)) # Sort by updated_date, with most recent first documents.sort(key=lambda x: x.updated_date, reverse=True) return render_template('category_documents.html', category=category, documents=documents) @main.route('/document/', methods=['GET']) @login_required def view_document(doc_id): """View a document in read mode""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() return render_template('document_view.html', document=document) @main.route('/document//edit', methods=['GET']) @login_required def edit_document(doc_id): """Edit a document with the Vim editor""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() categories = Category.query.filter_by(user_id=current_user.id).all() tags = Tag.query.filter_by(user_id=current_user.id).all() # Convert categories to dictionaries for JSON serialization categories_dict = build_category_hierarchy(categories) return render_template('document_edit.html', document=document, categories=categories_dict, tags=tags) @main.route('/document/new', methods=['GET']) @login_required def new_document(): """Create a new document with the Vim editor""" categories = Category.query.filter_by(user_id=current_user.id).all() tags = Tag.query.filter_by(user_id=current_user.id).all() category_id = request.args.get('category') document = None preselected_category_id = None # Find the root category root_category = Category.query.filter_by(user_id=current_user.id, is_root=True).first() if category_id: # If a specific category was requested, use that category = Category.query.filter_by(id=category_id, user_id=current_user.id).first() if category: preselected_category_id = int(category_id) elif root_category: # Otherwise default to the root category preselected_category_id = root_category.id # Create a blank document document = Document( title="Untitled Document", content="", category_id=preselected_category_id, user_id=current_user.id ) # Convert categories to dictionaries for JSON serialization categories_dict = build_category_hierarchy(categories) return render_template('document_edit.html', document=document, categories=categories_dict, tags=tags, preselected_category_id=preselected_category_id) @main.route('/api/document', methods=['POST']) @login_required def save_document(): """Save a document (new or existing)""" data = request.json # Find the root category to use as default root_category = Category.query.filter_by(user_id=current_user.id, is_root=True).first() # Ensure we have a root category if not root_category: # Create a root category if it doesn't exist root_category = Category( name='', icon='mdi-folder-outline', description='Default container for documents and categories', user_id=current_user.id, is_root=True ) db.session.add(root_category) db.session.flush() # Get the ID without committing yet # All documents must have a category - defaults to root if 'id' in data and data['id']: # Update existing document - verify ownership document = Document.query.filter_by(id=data['id'], user_id=current_user.id).first_or_404() document.title = data['title'] document.content = data['content'] document.category_id = data['category_id'] if data['category_id'] else root_category.id else: # Create new document document = Document( title=data['title'], content=data['content'], category_id=data['category_id'] if data['category_id'] else root_category.id, user_id=current_user.id ) db.session.add(document) # Handle tags if 'tags' in data: # Clear existing tags document.tags = [] # Process tags, preventing duplicates processed_tag_names = set() for tag_name in data['tags']: # Skip if we've already added this tag (case insensitive) if tag_name.lower() in processed_tag_names: continue # Add to our processed set to prevent duplicates in this request processed_tag_names.add(tag_name.lower()) # Check if tag already exists for this user tag = Tag.query.filter( Tag.name.ilike(tag_name), Tag.user_id == current_user.id ).first() if not tag: # Create new tag tag = Tag(name=tag_name, user_id=current_user.id) db.session.add(tag) # Add tag to document document.tags.append(tag) db.session.commit() return jsonify(document.to_dict()) @main.route('/api/document/', methods=['DELETE']) @login_required def delete_document(doc_id): """Delete a document""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() db.session.delete(document) db.session.commit() return jsonify({'success': True}) @main.route('/api/document/', methods=['GET']) @login_required def get_document(doc_id): """Get a document by ID""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() return jsonify(document.to_dict()) @main.route('/api/document/', methods=['PATCH']) @login_required def update_document(doc_id): """Update a document's properties (like category)""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() data = request.json # Update category if provided if 'category_id' in data and data['category_id']: # Verify the category exists and belongs to user category = Category.query.filter_by(id=data['category_id'], user_id=current_user.id).first_or_404() document.category_id = category.id db.session.commit() return jsonify(document.to_dict()) @main.route('/api/category', methods=['POST']) @login_required def save_category(): """Save a category (new or existing)""" data = request.json if 'id' in data and data['id']: # Update existing category - verify ownership category = Category.query.filter_by(id=data['id'], user_id=current_user.id).first_or_404() category.name = data['name'] category.icon = data['icon'] category.description = data.get('description', '') # Only change parent if it belongs to the same user if data.get('parent_id'): parent = Category.query.filter_by(id=data['parent_id'], user_id=current_user.id).first() if parent: category.parent_id = parent.id else: # Create new category category = Category( name=data['name'], icon=data['icon'], description=data.get('description', ''), parent_id=data['parent_id'] if data.get('parent_id') else None, user_id=current_user.id ) db.session.add(category) db.session.commit() return jsonify(category.to_dict()) @main.route('/api/category/', methods=['DELETE']) @login_required def delete_category(category_id): """Delete a category and optionally reassign documents""" category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404() # Can't delete root category if category.is_root: return jsonify({'error': 'Cannot delete root category'}), 400 # Check if we should preserve contents preserve_contents = request.args.get('preserve_contents', 'false').lower() == 'true' # Find parent category or root parent_category = None if category.parent_id: parent_category = Category.query.filter_by(id=category.parent_id, user_id=current_user.id).first() if not parent_category: # If no parent or parent not found, use root category parent_category = Category.query.filter_by(user_id=current_user.id, is_root=True).first() if preserve_contents: # Move documents to parent category for doc in category.documents: doc.category_id = parent_category.id # Move child categories to parent for child in category.children: child.parent_id = parent_category.id else: # Delete all documents in this category for doc in category.documents: db.session.delete(doc) # Recursively delete subcategories and their documents def delete_subcategories(parent): for child in parent.children: # Delete all documents in this subcategory for doc in child.documents: db.session.delete(doc) # Recursively delete subcategories delete_subcategories(child) # Delete the subcategory itself db.session.delete(child) # Start recursive deletion delete_subcategories(category) # Delete the category itself db.session.delete(category) db.session.commit() return jsonify({'success': True}) @main.route('/search') def search_documents(): query = request.args.get('q', '') user_id = current_user.id if not query: return jsonify([]) # Parse hashtags from the query hashtags = re.findall(r'#(\w+)', query) # Remove hashtags from the main query text cleaned_query = re.sub(r'#\w+', '', query).strip() # Base query for documents base_query = Document.query.filter_by(user_id=user_id) results = [] # If we have a cleaned query (non-hashtag part), search by title and content if cleaned_query: title_content_results = base_query.filter( or_( Document.title.ilike(f'%{cleaned_query}%'), Document.content.ilike(f'%{cleaned_query}%') ) ).all() results.extend(title_content_results) # If we have hashtags, search by tags (OR operation between tags) if hashtags: tag_results = base_query.join(document_tags).join(Tag).filter( Tag.name.in_(hashtags) ).all() # Add unique tag results to the results list for doc in tag_results: if doc not in results: results.append(doc) # If no specific search criteria (cleaned query or hashtags), return empty list if not cleaned_query and not hashtags: return jsonify([]) # Convert to list of dictionaries for JSON response docs_list = [] for doc in results: doc_dict = { 'id': doc.id, 'title': doc.title, 'preview': doc.content[:150] + '...' if len(doc.content) > 150 else doc.content, 'category_id': doc.category_id, 'created_at': doc.created_at.strftime('%Y-%m-%d %H:%M'), 'updated_at': doc.updated_at.strftime('%Y-%m-%d %H:%M') if doc.updated_at else None, 'tags': [tag.name for tag in doc.tags] } docs_list.append(doc_dict) return jsonify(docs_list) @main.route('/api/tags', methods=['GET']) @login_required def get_tags(): """Get all tags for the current user""" tags = Tag.query.filter_by(user_id=current_user.id).all() return jsonify({'tags': [tag.to_dict() for tag in tags]}) @main.route('/document//export', methods=['GET']) @login_required def export_document(doc_id): """Export a document as a markdown file""" document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404() # Create a file-like object in memory file_data = io.BytesIO(document.content.encode('utf-8')) file_data.seek(0) return send_file( file_data, mimetype='text/markdown', as_attachment=True, download_name=f"{document.title.replace(' ', '_')}.md" ) @main.route('/api/categories', methods=['GET']) @login_required def get_categories(): """Get all root categories with their children for the current user""" root_categories = Category.query.filter_by( user_id=current_user.id, parent_id=None ).all() return jsonify([category.to_dict() for category in root_categories]) @main.route('/category/new', methods=['GET']) @login_required def new_category(): """Create a new category or subcategory""" parent_id = request.args.get('parent_id') parent = None if parent_id: parent = Category.query.filter_by(id=parent_id, user_id=current_user.id).first_or_404() return render_template('category_edit.html', category=None, parent=parent) else: # No parent specified, creating a top-level category return render_template('category_edit.html', category=None, parent=None) @main.route('/category//edit', methods=['GET']) @login_required def edit_category(category_id): """Edit an existing category""" category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404() return render_template('category_edit.html', category=category, parent=category.parent)