""" Note models for markdown-based documentation and knowledge management. Migrated from models_old.py to maintain consistency with the new modular structure. """ import enum import re from datetime import datetime, timedelta from sqlalchemy import UniqueConstraint from . import db from .enums import Role class NoteVisibility(enum.Enum): """Note sharing visibility levels""" PRIVATE = "Private" TEAM = "Team" COMPANY = "Company" class Note(db.Model): """Markdown notes with sharing capabilities""" id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) content = db.Column(db.Text, nullable=False) # Markdown content slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier # Visibility and sharing visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE) # Folder organization folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal" # Metadata created_at = db.Column(db.DateTime, default=datetime.now) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) # Associations created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) # Optional associations team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task # Tags for organization tags = db.Column(db.String(500)) # Comma-separated tags # Pin important notes is_pinned = db.Column(db.Boolean, default=False) # File-based note support is_file_based = db.Column(db.Boolean, default=False) # True if note was created from uploaded file file_path = db.Column(db.String(500), nullable=True) # Path to uploaded file file_type = db.Column(db.String(20), nullable=True) # 'markdown', 'image', etc. original_filename = db.Column(db.String(255), nullable=True) # Original uploaded filename file_size = db.Column(db.Integer, nullable=True) # File size in bytes mime_type = db.Column(db.String(100), nullable=True) # MIME type of file # For images image_width = db.Column(db.Integer, nullable=True) image_height = db.Column(db.Integer, nullable=True) # Soft delete is_archived = db.Column(db.Boolean, default=False) archived_at = db.Column(db.DateTime, nullable=True) # Relationships created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes') company = db.relationship('Company', backref='notes') team = db.relationship('Team', backref='notes') project = db.relationship('Project', backref='notes') task = db.relationship('Task', backref='notes') # Unique constraint on slug per company __table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),) def __repr__(self): return f'' def generate_slug(self): """Generate URL-friendly slug from title and set it on the model""" import re # Remove special characters and convert to lowercase slug = re.sub(r'[^\w\s-]', '', self.title.lower()) # Replace spaces with hyphens slug = re.sub(r'[-\s]+', '-', slug) # Remove leading/trailing hyphens slug = slug.strip('-') # Ensure uniqueness within company base_slug = slug counter = 1 while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug return slug def can_user_view(self, user): """Check if user can view this note""" # Creator can always view if user.id == self.created_by_id: return True # Check company match if user.company_id != self.company_id: return False # Check visibility if self.visibility == NoteVisibility.COMPANY: return True elif self.visibility == NoteVisibility.TEAM: # Check if user is in the same team if self.team_id and user.team_id == self.team_id: return True # Admins can view all team notes if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]: return True return False def can_user_edit(self, user): """Check if user can edit this note""" # Creator can always edit if user.id == self.created_by_id: return True # Admins can edit company notes if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id: return True return False def get_tags_list(self): """Get tags as a list""" if not self.tags: return [] return [tag.strip() for tag in self.tags.split(',') if tag.strip()] def set_tags_list(self, tags_list): """Set tags from a list""" self.tags = ','.join(tags_list) if tags_list else None def get_preview(self, length=200): """Get a plain text preview of the note content""" # Strip markdown formatting for preview import re from frontmatter_utils import parse_frontmatter # Extract body content without frontmatter _, body = parse_frontmatter(self.content) text = body # Remove headers text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE) # Remove emphasis text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text) text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text) # Remove links text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) # Remove code blocks text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL) text = re.sub(r'`([^`]+)`', r'\1', text) # Clean up whitespace text = ' '.join(text.split()) if len(text) > length: return text[:length] + '...' return text def render_html(self): """Render markdown content to HTML with Wiki-style link support""" try: import markdown from frontmatter_utils import parse_frontmatter from wiki_links import process_wiki_links # Extract body content without frontmatter _, body = parse_frontmatter(self.content) # Process Wiki-style links before markdown rendering body = process_wiki_links(body, current_note_id=self.id) # Use extensions for better markdown support html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc']) return html except ImportError: # Fallback if markdown not installed return f'
{self.content}
' def get_frontmatter(self): """Get frontmatter metadata from content""" from frontmatter_utils import parse_frontmatter metadata, _ = parse_frontmatter(self.content) return metadata def update_frontmatter(self): """Update content with current metadata as frontmatter""" from frontmatter_utils import update_frontmatter metadata = { 'title': self.title, 'visibility': self.visibility.value.lower(), 'folder': self.folder, 'tags': self.get_tags_list() if self.tags else None, 'project': self.project.code if self.project else None, 'task_id': self.task_id, 'pinned': self.is_pinned if self.is_pinned else None, 'created': self.created_at.isoformat() if self.created_at else None, 'updated': self.updated_at.isoformat() if self.updated_at else None, 'author': self.created_by.username if self.created_by else None } # Remove None values metadata = {k: v for k, v in metadata.items() if v is not None} self.content = update_frontmatter(self.content, metadata) def sync_from_frontmatter(self): """Update model fields from frontmatter in content""" from frontmatter_utils import parse_frontmatter metadata, _ = parse_frontmatter(self.content) if metadata: # Update fields from frontmatter if 'title' in metadata: self.title = metadata['title'] if 'visibility' in metadata: try: self.visibility = NoteVisibility[metadata['visibility'].upper()] except KeyError: pass if 'folder' in metadata: self.folder = metadata['folder'] if 'tags' in metadata: if isinstance(metadata['tags'], list): self.set_tags_list(metadata['tags']) elif isinstance(metadata['tags'], str): self.tags = metadata['tags'] if 'pinned' in metadata: self.is_pinned = bool(metadata['pinned']) def create_share_link(self, expires_in_days=None, password=None, max_views=None, created_by=None): """Create a public share link for this note""" from .note_share import NoteShare from flask import g share = NoteShare( note_id=self.id, created_by_id=created_by.id if created_by else g.user.id ) # Set expiration if expires_in_days: share.expires_at = datetime.now() + timedelta(days=expires_in_days) # Set password if password: share.set_password(password) # Set view limit if max_views: share.max_views = max_views db.session.add(share) return share def get_active_shares(self): """Get all active share links for this note""" return [s for s in self.shares if s.is_valid()] def get_all_shares(self): """Get all share links for this note""" from models.note_share import NoteShare return self.shares.order_by(NoteShare.created_at.desc()).all() def has_active_shares(self): """Check if this note has any active share links""" return any(s.is_valid() for s in self.shares) @property def is_image(self): """Check if this is an image note""" return self.file_type == 'image' @property def is_markdown_file(self): """Check if this is a markdown file note""" return self.file_type == 'markdown' @property def is_pdf(self): """Check if this is a PDF note""" return self.file_type == 'document' and self.original_filename and self.original_filename.lower().endswith('.pdf') @property def file_url(self): """Get the URL to access the uploaded file""" if self.file_path and self.id: from flask import url_for return url_for('notes_api.serve_note_file', note_id=self.id) return None @property def thumbnail_url(self): """Get thumbnail URL for image notes""" if self.is_image and self.file_path: # Could implement thumbnail generation later return self.file_url return None @staticmethod def allowed_file(filename): """Check if file extension is allowed""" ALLOWED_EXTENSIONS = { 'markdown': {'md', 'markdown', 'mdown', 'mkd'}, 'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}, 'text': {'txt'}, 'document': {'pdf', 'doc', 'docx'} } if '.' not in filename: return False ext = filename.rsplit('.', 1)[1].lower() # Check all allowed extensions for file_type, extensions in ALLOWED_EXTENSIONS.items(): if ext in extensions: return True return False @staticmethod def get_file_type_from_filename(filename): """Determine file type from extension""" if '.' not in filename: return 'unknown' ext = filename.rsplit('.', 1)[1].lower() if ext in {'md', 'markdown', 'mdown', 'mkd'}: return 'markdown' elif ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}: return 'image' elif ext == 'txt': return 'text' elif ext in {'pdf', 'doc', 'docx'}: return 'document' else: return 'other' class NoteLink(db.Model): """Links between notes for creating relationships""" id = db.Column(db.Integer, primary_key=True) # Source and target notes with cascade deletion source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False) target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False) # Link metadata link_type = db.Column(db.String(50), default='related') # related, parent, child, etc. created_at = db.Column(db.DateTime, default=datetime.now) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) # Relationships with cascade deletion source_note = db.relationship('Note', foreign_keys=[source_note_id], backref=db.backref('outgoing_links', cascade='all, delete-orphan')) target_note = db.relationship('Note', foreign_keys=[target_note_id], backref=db.backref('incoming_links', cascade='all, delete-orphan')) created_by = db.relationship('User', foreign_keys=[created_by_id]) # Unique constraint to prevent duplicate links __table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),) def __repr__(self): return f' {self.target_note_id}>' class NoteFolder(db.Model): """Represents a folder for organizing notes""" id = db.Column(db.Integer, primary_key=True) # Folder properties name = db.Column(db.String(100), nullable=False) path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1" parent_path = db.Column(db.String(500), nullable=True) # Parent folder path description = db.Column(db.Text, nullable=True) # Metadata created_at = db.Column(db.DateTime, default=datetime.now) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) # Relationships created_by = db.relationship('User', foreign_keys=[created_by_id]) company = db.relationship('Company', foreign_keys=[company_id]) # Unique constraint to prevent duplicate paths within a company __table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),) def __repr__(self): return f''