Add initial version of Markdown Notes feature.

This commit is contained in:
2025-07-06 19:40:34 +02:00
parent 4214e88d18
commit 13026876f8
13 changed files with 2395 additions and 2 deletions

468
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, Note, NoteLink, NoteVisibility
from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data, format_burndown_data
@@ -1606,6 +1606,404 @@ def contact():
# redacted
return render_template('contact.html', title='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')
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
from sqlalchemy import or_, and_
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 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
all_tags = set()
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
all_tags.update(note.get_tags_list())
# Get projects for filter
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
return render_template('notes_list.html',
title='Notes',
notes=notes,
visibility_filter=visibility_filter,
tag_filter=tag_filter,
search_query=search_query,
all_tags=sorted(list(all_tags)),
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')
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
tags=','.join(tag_list) if tag_list else None,
created_by_id=g.user.id,
company_id=g.user.company_id
)
# 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 = []
return render_template('note_editor.html',
title='New Note',
note=None,
projects=projects,
tasks=tasks,
NoteVisibility=NoteVisibility)
@app.route('/notes/<slug>')
@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):
outgoing_links.append(link)
for link in note.incoming_links:
if link.source_note.can_user_view(g.user):
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/<slug>/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')
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.tags = ','.join(tag_list) if tag_list else None
# 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()
return render_template('note_editor.html',
title=f'Edit: {note.title}',
note=note,
projects=projects,
tasks=tasks,
NoteVisibility=NoteVisibility)
@app.route('/notes/<slug>/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('/api/notes/<int:note_id>/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
@@ -5941,6 +6339,74 @@ def search_sprints():
logger.error(f"Error in search_sprints: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Markdown rendering API
@app.route('/api/render-markdown', methods=['POST'])
@login_required
def render_markdown():
"""Render markdown content to HTML for preview"""
try:
data = request.get_json()
content = data.get('content', '')
if not content:
return jsonify({'html': ''})
# Import markdown here to avoid issues if not installed
import markdown
html = markdown.markdown(content, extensions=['extra', 'codehilite', 'toc'])
return jsonify({'html': html})
except Exception as e:
logger.error(f"Error rendering markdown: {str(e)}")
return jsonify({'html': '<p>Error rendering markdown</p>'})
# Note link deletion endpoint
@app.route('/api/notes/<int:note_id>/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))
app.run(debug=True, host='0.0.0.0', port=port)

View File

@@ -76,6 +76,7 @@ def run_all_migrations(db_path=None):
migrate_system_events(db_path)
migrate_dashboard_system(db_path)
migrate_comment_system(db_path)
migrate_notes_system(db_path)
# Run PostgreSQL-specific migrations if applicable
if FLASK_AVAILABLE:
@@ -1272,6 +1273,74 @@ def migrate_postgresql_schema():
"""))
db.session.commit()
# Check if note table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note'
"""))
if not result.fetchone():
print("Creating note and note_link tables...")
# Create NoteVisibility enum type
db.session.execute(text("""
DO $$ BEGIN
CREATE TYPE notevisibility AS ENUM ('Private', 'Team', 'Company');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"""))
db.session.execute(text("""
CREATE TABLE note (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility notevisibility NOT NULL DEFAULT 'Private',
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
task_id INTEGER,
tags TEXT[],
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
FOREIGN KEY (project_id) REFERENCES project (id),
FOREIGN KEY (task_id) REFERENCES task (id)
)
"""))
# Create note_link table
db.session.execute(text("""
CREATE TABLE note_link (
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source_note_id, target_note_id),
FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE
)
"""))
# Create indexes
db.session.execute(text("CREATE INDEX idx_note_company ON note(company_id)"))
db.session.execute(text("CREATE INDEX idx_note_created_by ON note(created_by_id)"))
db.session.execute(text("CREATE INDEX idx_note_project ON note(project_id)"))
db.session.execute(text("CREATE INDEX idx_note_task ON note(task_id)"))
db.session.execute(text("CREATE INDEX idx_note_slug ON note(company_id, slug)"))
db.session.execute(text("CREATE INDEX idx_note_visibility ON note(visibility)"))
db.session.execute(text("CREATE INDEX idx_note_archived ON note(archived)"))
db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)"))
db.session.execute(text("CREATE INDEX idx_note_link_source ON note_link(source_note_id)"))
db.session.execute(text("CREATE INDEX idx_note_link_target ON note_link(target_note_id)"))
db.session.commit()
print("PostgreSQL schema migration completed successfully!")
except Exception as e:
@@ -1482,6 +1551,94 @@ def migrate_comment_system(db_file=None):
conn.close()
def migrate_notes_system(db_file=None):
"""Migrate to add Notes system with markdown support."""
db_path = get_db_path(db_file)
print(f"Migrating Notes system in {db_path}...")
if not os.path.exists(db_path):
print(f"Database file {db_path} does not exist. Run basic migration first.")
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if note table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note'")
if cursor.fetchone():
print("Note table already exists. Skipping migration.")
return True
print("Creating Notes system tables...")
# Create note table
cursor.execute("""
CREATE TABLE note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'Private',
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
task_id INTEGER,
tags TEXT,
archived BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES user (id),
FOREIGN KEY (project_id) REFERENCES project (id),
FOREIGN KEY (task_id) REFERENCES task (id)
)
""")
# Create note_link table for linking notes
cursor.execute("""
CREATE TABLE note_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (created_by_id) REFERENCES user (id),
UNIQUE(source_note_id, target_note_id)
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_note_company ON note(company_id)")
cursor.execute("CREATE INDEX idx_note_created_by ON note(created_by_id)")
cursor.execute("CREATE INDEX idx_note_project ON note(project_id)")
cursor.execute("CREATE INDEX idx_note_task ON note(task_id)")
cursor.execute("CREATE INDEX idx_note_slug ON note(company_id, slug)")
cursor.execute("CREATE INDEX idx_note_visibility ON note(visibility)")
cursor.execute("CREATE INDEX idx_note_archived ON note(archived)")
cursor.execute("CREATE INDEX idx_note_created_at ON note(created_at DESC)")
# Create indexes for note links
cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)")
cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)")
conn.commit()
print("Notes system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Notes system migration: {e}")
conn.rollback()
return False
finally:
conn.close()
def main():
"""Main function with command line interface."""
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')

178
models.py
View File

@@ -1241,4 +1241,180 @@ class WidgetTemplate(db.Model):
user_level = role_hierarchy.get(user.role, 0)
required_level = role_hierarchy.get(self.required_role, 0)
return user_level >= required_level
return user_level >= required_level
# Note Sharing Visibility
class NoteVisibility(enum.Enum):
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)
# 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)
# 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'<Note {self.title}>'
def generate_slug(self):
"""Generate URL-friendly slug from title"""
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
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
text = self.content
# 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"""
try:
import markdown
# Use extensions for better markdown support
html = markdown.markdown(self.content, extensions=['extra', 'codehilite', 'toc'])
return html
except ImportError:
# Fallback if markdown not installed
return f'<pre>{self.content}</pre>'
class NoteLink(db.Model):
"""Links between notes for creating relationships"""
id = db.Column(db.Integer, primary_key=True)
# Source and target notes
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id'), nullable=False)
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id'), 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
source_note = db.relationship('Note', foreign_keys=[source_note_id], backref='outgoing_links')
target_note = db.relationship('Note', foreign_keys=[target_note_id], backref='incoming_links')
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'<NoteLink {self.source_note_id} -> {self.target_note_id}>'

View File

@@ -14,3 +14,4 @@ pandas==1.5.3
xlsxwriter==3.1.2
Flask-Mail==0.9.1
psycopg2-binary==2.9.9
markdown==3.4.4

23
static/js/ace/ace.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
define("ace/theme/github-css",["require","exports","module"],function(e,t,n){n.exports='/* CSS style content from github\'s default pygments highlighter template.\n Cursor and selection styles from textmate.css. */\n.ace-github .ace_gutter {\n background: #e8e8e8;\n color: #AAA;\n}\n\n.ace-github {\n background: #fff;\n color: #000;\n}\n\n.ace-github .ace_keyword {\n font-weight: bold;\n}\n\n.ace-github .ace_string {\n color: #D14;\n}\n\n.ace-github .ace_variable.ace_class {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_numeric {\n color: #099;\n}\n\n.ace-github .ace_constant.ace_buildin {\n color: #0086B3;\n}\n\n.ace-github .ace_support.ace_function {\n color: #0086B3;\n}\n\n.ace-github .ace_comment {\n color: #998;\n font-style: italic;\n}\n\n.ace-github .ace_variable.ace_language {\n color: #0086B3;\n}\n\n.ace-github .ace_paren {\n font-weight: bold;\n}\n\n.ace-github .ace_boolean {\n font-weight: bold;\n}\n\n.ace-github .ace_string.ace_regexp {\n color: #009926;\n font-weight: normal;\n}\n\n.ace-github .ace_variable.ace_instance {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_language {\n font-weight: bold;\n}\n\n.ace-github .ace_cursor {\n color: black;\n}\n\n.ace-github.ace_focus .ace_marker-layer .ace_active-line {\n background: rgb(255, 255, 204);\n}\n.ace-github .ace_marker-layer .ace_active-line {\n background: rgb(245, 245, 245);\n}\n\n.ace-github .ace_marker-layer .ace_selection {\n background: rgb(181, 213, 255);\n}\n\n.ace-github.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px white;\n}\n/* bold keywords cause cursor issues for some fonts */\n/* this disables bold style for editor and keeps for static highlighter */\n.ace-github.ace_nobold .ace_line > span {\n font-weight: normal !important;\n}\n\n.ace-github .ace_marker-layer .ace_step {\n background: rgb(252, 255, 0);\n}\n\n.ace-github .ace_marker-layer .ace_stack {\n background: rgb(164, 229, 101);\n}\n\n.ace-github .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid rgb(192, 192, 192);\n}\n\n.ace-github .ace_gutter-active-line {\n background-color : rgba(0, 0, 0, 0.07);\n}\n\n.ace-github .ace_marker-layer .ace_selected-word {\n background: rgb(250, 250, 255);\n border: 1px solid rgb(200, 200, 250);\n}\n\n.ace-github .ace_invisible {\n color: #BFBFBF\n}\n\n.ace-github .ace_print-margin {\n width: 1px;\n background: #e8e8e8;\n}\n\n.ace-github .ace_indent-guide {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;\n}\n\n.ace-github .ace_indent-guide-active {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAAZSURBVHjaYvj///9/hivKyv8BAAAA//8DACLqBhbvk+/eAAAAAElFTkSuQmCC") right repeat-y;\n}\n'}),define("ace/theme/github",["require","exports","module","ace/theme/github-css","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-github",t.cssText=e("./github-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
window.require(["ace/theme/github"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

View File

@@ -0,0 +1,8 @@
define("ace/theme/monokai-css",["require","exports","module"],function(e,t,n){n.exports=".ace-monokai .ace_gutter {\n background: #2F3129;\n color: #8F908A\n}\n\n.ace-monokai .ace_print-margin {\n width: 1px;\n background: #555651\n}\n\n.ace-monokai {\n background-color: #272822;\n color: #F8F8F2\n}\n\n.ace-monokai .ace_cursor {\n color: #F8F8F0\n}\n\n.ace-monokai .ace_marker-layer .ace_selection {\n background: #49483E\n}\n\n.ace-monokai.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px #272822;\n}\n\n.ace-monokai .ace_marker-layer .ace_step {\n background: rgb(102, 82, 0)\n}\n\n.ace-monokai .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_marker-layer .ace_active-line {\n background: #202020\n}\n\n.ace-monokai .ace_gutter-active-line {\n background-color: #272727\n}\n\n.ace-monokai .ace_marker-layer .ace_selected-word {\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_invisible {\n color: #52524d\n}\n\n.ace-monokai .ace_entity.ace_name.ace_tag,\n.ace-monokai .ace_keyword,\n.ace-monokai .ace_meta.ace_tag,\n.ace-monokai .ace_storage {\n color: #F92672\n}\n\n.ace-monokai .ace_punctuation,\n.ace-monokai .ace_punctuation.ace_tag {\n color: #fff\n}\n\n.ace-monokai .ace_constant.ace_character,\n.ace-monokai .ace_constant.ace_language,\n.ace-monokai .ace_constant.ace_numeric,\n.ace-monokai .ace_constant.ace_other {\n color: #AE81FF\n}\n\n.ace-monokai .ace_invalid {\n color: #F8F8F0;\n background-color: #F92672\n}\n\n.ace-monokai .ace_invalid.ace_deprecated {\n color: #F8F8F0;\n background-color: #AE81FF\n}\n\n.ace-monokai .ace_support.ace_constant,\n.ace-monokai .ace_support.ace_function {\n color: #66D9EF\n}\n\n.ace-monokai .ace_fold {\n background-color: #A6E22E;\n border-color: #F8F8F2\n}\n\n.ace-monokai .ace_storage.ace_type,\n.ace-monokai .ace_support.ace_class,\n.ace-monokai .ace_support.ace_type {\n font-style: italic;\n color: #66D9EF\n}\n\n.ace-monokai .ace_entity.ace_name.ace_function,\n.ace-monokai .ace_entity.ace_other,\n.ace-monokai .ace_entity.ace_other.ace_attribute-name,\n.ace-monokai .ace_variable {\n color: #A6E22E\n}\n\n.ace-monokai .ace_variable.ace_parameter {\n font-style: italic;\n color: #FD971F\n}\n\n.ace-monokai .ace_string {\n color: #E6DB74\n}\n\n.ace-monokai .ace_comment {\n color: #75715E\n}\n\n.ace-monokai .ace_indent-guide {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWPQ0FD0ZXBzd/wPAAjVAoxeSgNeAAAAAElFTkSuQmCC) right repeat-y\n}\n\n.ace-monokai .ace_indent-guide-active {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQIW2PQ1dX9zzBz5sz/ABCcBFFentLlAAAAAElFTkSuQmCC) right repeat-y;\n}\n"}),define("ace/theme/monokai",["require","exports","module","ace/theme/monokai-css","ace/lib/dom"],function(e,t,n){t.isDark=!0,t.cssClass="ace-monokai",t.cssText=e("./monokai-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
window.require(["ace/theme/monokai"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

View File

@@ -132,6 +132,7 @@
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
<li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃‍♂️</i><span class="nav-text">Sprints</span></a></li>
<li><a href="{{ url_for('notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
<!-- Role-based menu items -->

600
templates/note_editor.html Normal file
View File

@@ -0,0 +1,600 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container note-editor-container">
<div class="editor-header">
<h2>{% if note %}Edit Note{% else %}Create Note{% endif %}</h2>
<div class="editor-actions">
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
<form method="POST" id="note-form">
<div class="editor-layout">
<!-- Editor Panel -->
<div class="editor-panel">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" class="form-control"
value="{{ note.title if note else '' }}" required autofocus>
</div>
<div class="form-row">
<div class="form-group">
<label for="visibility">Visibility</label>
<select id="visibility" name="visibility" class="form-control" required>
<option value="Private" {% if not note or note.visibility.value == 'Private' %}selected{% endif %}>
🔒 Private - Only you can see this
</option>
<option value="Team" {% if note and note.visibility.value == 'Team' %}selected{% endif %}>
👥 Team - Your team members can see this
</option>
<option value="Company" {% if note and note.visibility.value == 'Company' %}selected{% endif %}>
🏢 Company - Everyone in your company can see this
</option>
</select>
</div>
<div class="form-group">
<label for="project_id">Project (Optional)</label>
<select id="project_id" name="project_id" class="form-control">
<option value="">No project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if note and note.project_id == project.id %}selected{% endif %}>
{{ project.code }} - {{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="task_id">Task (Optional)</label>
<select id="task_id" name="task_id" class="form-control">
<option value="">No task</option>
{% for task in tasks %}
<option value="{{ task.id }}" {% if note and note.task_id == task.id %}selected{% endif %}>
#{{ task.id }} - {{ task.title }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" class="form-control"
placeholder="documentation, meeting-notes, technical"
value="{{ ', '.join(note.tags) if note and note.tags else '' }}">
</div>
<div class="form-group editor-group">
<label for="content">Content (Markdown)</label>
<div class="editor-toolbar">
<button type="button" class="toolbar-btn toolbar-bold" onclick="insertMarkdown('**', '**')" title="Bold">
<b>B</b>
</button>
<button type="button" class="toolbar-btn toolbar-italic" onclick="insertMarkdown('*', '*')" title="Italic">
<i>I</i>
</button>
<button type="button" class="toolbar-btn toolbar-code" onclick="insertMarkdown('`', '`')" title="Inline Code">
&lt;/&gt;
</button>
<button type="button" class="toolbar-btn toolbar-code-block" onclick="insertMarkdown('```\\n', '\\n```')" title="Code Block">
[&nbsp;]
</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('# ', '')" title="Heading 1">
H1
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('## ', '')" title="Heading 2">
H2
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('### ', '')" title="Heading 3">
H3
</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('- ', '')" title="Bullet List">
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('1. ', '')" title="Numbered List">
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('> ', '')" title="Quote">
</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('[', '](url)')" title="Link">
🔗
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('![', '](url)')" title="Image">
🖼
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('---\\n', '')" title="Horizontal Rule">
</button>
</div>
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '' }}</div>
<textarea id="content" name="content" class="form-control markdown-editor"
style="display: none;" required>{{ note.content if note else '' }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if note %}Update Note{% else %}Create Note{% endif %}
</button>
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
<!-- Preview Panel -->
<div class="preview-panel">
<h3>Preview</h3>
<div id="preview-content" class="markdown-content">
<p class="preview-placeholder">Start typing to see the preview...</p>
</div>
</div>
</div>
</form>
{% if note and note.linked_notes %}
<div class="linked-notes-section">
<h3>Linked Notes</h3>
<div class="linked-notes-list">
{% for link in note.linked_notes %}
<div class="linked-note-item">
<a href="{{ url_for('view_note', slug=link.target_note.slug) }}">
{{ link.target_note.title }}
</a>
<span class="link-type">{{ link.link_type }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<style>
/* Note editor specific styles */
.note-editor-container {
max-width: none !important;
width: 100% !important;
padding: 1rem !important;
margin: 0 !important;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.editor-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
min-height: 600px;
}
.editor-panel, .preview-panel {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
}
.preview-panel {
background: #f8f9fa;
}
.preview-panel h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #666;
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
.editor-toolbar {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-bottom: none;
border-radius: 6px 6px 0 0;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 0.5rem 0.75rem;
min-width: 40px;
border: 1px solid #dee2e6;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: #333;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
color: #000;
}
.toolbar-btn:active {
background: #dee2e6;
transform: translateY(1px);
}
/* Special styling for specific buttons */
.toolbar-bold {
font-weight: bold;
}
.toolbar-italic {
font-style: italic;
}
.toolbar-code, .toolbar-code-block {
font-family: monospace;
}
/* Ensure button content is visible */
.toolbar-btn b, .toolbar-btn i {
font-size: 1rem;
line-height: 1;
}
.toolbar-separator {
width: 1px;
background: #dee2e6;
margin: 0 0.5rem;
}
.markdown-editor {
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.5;
border-radius: 0 0 6px 6px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.ace-editor-container {
width: 100%;
height: 500px;
border: 2px solid #e9ecef;
border-radius: 0 0 6px 6px;
border-top: none;
font-size: 14px;
}
.markdown-content {
padding: 1rem;
min-height: 400px;
max-height: 700px;
overflow-y: auto;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.markdown-content h1 { font-size: 2rem; }
.markdown-content h2 { font-size: 1.5rem; }
.markdown-content h3 { font-size: 1.25rem; }
.markdown-content p {
margin-bottom: 1rem;
}
.markdown-content code {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #dee2e6;
padding-left: 1rem;
margin: 1rem 0;
color: #666;
}
.markdown-content ul, .markdown-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.markdown-content th, .markdown-content td {
border: 1px solid #dee2e6;
padding: 0.5rem;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.preview-placeholder {
color: #999;
font-style: italic;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.linked-notes-section {
margin-top: 2rem;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
}
.linked-notes-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.linked-note-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.link-type {
font-size: 0.85rem;
color: #666;
font-style: italic;
}
/* Responsive design */
@media (max-width: 1024px) {
.editor-layout {
grid-template-columns: 1fr;
}
.preview-panel {
order: -1;
min-height: 300px;
max-height: 400px;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>
<!-- Load Ace Editor -->
<script src="{{ url_for('static', filename='js/ace/ace.js') }}"></script>
<script src="{{ url_for('static', filename='js/ace/mode-markdown.js') }}"></script>
<script src="{{ url_for('static', filename='js/ace/theme-github.js') }}"></script>
<script src="{{ url_for('static', filename='js/ace/theme-monokai.js') }}"></script>
<script src="{{ url_for('static', filename='js/ace/ext-language_tools.js') }}"></script>
<script>
// Global Ace Editor instance
let aceEditor;
// Markdown toolbar functions for Ace Editor
function insertMarkdown(before, after) {
if (!aceEditor) return;
const session = aceEditor.getSession();
const selection = aceEditor.getSelection();
const range = selection.getRange();
const selectedText = session.getTextRange(range);
if (selectedText) {
// Replace selected text
session.replace(range, before + selectedText + after);
// Select the text between the markdown markers
const newRange = selection.getRange();
newRange.setStart(newRange.start.row, newRange.start.column + before.length);
newRange.setEnd(newRange.start.row, newRange.start.column + selectedText.length);
selection.setRange(newRange);
} else {
// Insert at cursor position
const position = aceEditor.getCursorPosition();
session.insert(position, before + after);
// Move cursor between the markers
aceEditor.moveCursorTo(position.row, position.column + before.length);
}
aceEditor.focus();
syncContentAndUpdatePreview();
}
// Sync Ace Editor content with hidden textarea and update preview
function syncContentAndUpdatePreview() {
if (!aceEditor) return;
const content = aceEditor.getValue();
document.getElementById('content').value = content;
updatePreview();
}
// Live preview update
let previewTimer;
function updatePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
const content = document.getElementById('content').value;
const preview = document.getElementById('preview-content');
if (content.trim() === '') {
preview.innerHTML = '<p class="preview-placeholder">Start typing to see the preview...</p>';
return;
}
// Send content to server for markdown rendering
fetch('/api/render-markdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
})
.then(response => response.json())
.then(data => {
if (data.html) {
preview.innerHTML = data.html;
}
})
.catch(error => {
console.error('Error rendering markdown:', error);
});
}, 300);
}
// Initialize Ace Editor
function initializeAceEditor() {
// Create Ace Editor instance
aceEditor = ace.edit("ace-editor");
// Set theme (use github theme for light mode)
aceEditor.setTheme("ace/theme/github");
// Set markdown mode
aceEditor.session.setMode("ace/mode/markdown");
// Configure editor options
aceEditor.setOptions({
fontSize: "14px",
showPrintMargin: false,
showGutter: true,
highlightActiveLine: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
tabSize: 2,
useSoftTabs: true,
wrap: true,
showInvisibles: false,
scrollPastEnd: 0.5
});
// Set initial content from hidden textarea
const initialContent = document.getElementById('content').value;
aceEditor.setValue(initialContent, -1); // -1 moves cursor to start
// Listen for changes in Ace Editor
aceEditor.on('change', function() {
syncContentAndUpdatePreview();
});
// Handle form submission - ensure content is synced
document.getElementById('note-form').addEventListener('submit', function(e) {
syncContentAndUpdatePreview();
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
});
// Set focus to title field initially
document.getElementById('title').focus();
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Initialize Ace Editor
initializeAceEditor();
// Initial preview
updatePreview();
// Add keyboard shortcuts
if (aceEditor) {
aceEditor.commands.addCommand({
name: 'bold',
bindKey: {win: 'Ctrl-B', mac: 'Command-B'},
exec: function() { insertMarkdown('**', '**'); }
});
aceEditor.commands.addCommand({
name: 'italic',
bindKey: {win: 'Ctrl-I', mac: 'Command-I'},
exec: function() { insertMarkdown('*', '*'); }
});
aceEditor.commands.addCommand({
name: 'link',
bindKey: {win: 'Ctrl-K', mac: 'Command-K'},
exec: function() { insertMarkdown('[', '](url)'); }
});
}
});
</script>
{% endblock %}

566
templates/note_view.html Normal file
View File

@@ -0,0 +1,566 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container note-view-container">
<div class="note-header">
<div class="note-title-section">
<h1>{{ note.title }}</h1>
<div class="note-meta">
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
{% if note.visibility.value == 'Private' %}🔒{% elif note.visibility.value == 'Team' %}👥{% else %}🏢{% endif %}
{{ note.visibility.value }}
</span>
<span class="author">By {{ note.created_by.username }}</span>
<span class="date">Created {{ note.created_at|format_date }}</span>
{% if note.updated_at > note.created_at %}
<span class="date">· Updated {{ note.updated_at|format_date }}</span>
{% endif %}
</div>
</div>
<div class="note-actions">
{% if note.can_user_edit(g.user) %}
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-primary">Edit</a>
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this note?')">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
{% endif %}
<a href="{{ url_for('notes_list') }}" class="btn btn-secondary">Back to Notes</a>
</div>
</div>
<div class="note-associations">
{% if note.project %}
<div class="association-item">
<span class="association-label">Project:</span>
<span class="association-value">
<a href="{{ url_for('manage_project_tasks', project_id=note.project.id) }}">
{{ note.project.code }} - {{ note.project.name }}
</a>
</span>
</div>
{% endif %}
{% if note.task %}
<div class="association-item">
<span class="association-label">Task:</span>
<span class="association-value">
<a href="{{ url_for('view_task', task_id=note.task.id) }}">
#{{ note.task.id }} - {{ note.task.title }}
</a>
</span>
</div>
{% endif %}
{% if note.tags %}
<div class="association-item">
<span class="association-label">Tags:</span>
<span class="association-value">
{% for tag in note.tags %}
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
{% endfor %}
</span>
</div>
{% endif %}
</div>
<div class="note-content markdown-content">
{{ note.render_html()|safe }}
</div>
{% if note.linked_notes or note.can_user_edit(g.user) %}
<div class="linked-notes-section">
<div class="section-header">
<h3>Linked Notes</h3>
{% if note.can_user_edit(g.user) %}
<button id="add-link-btn" class="btn btn-sm btn-success">Add Link</button>
{% endif %}
</div>
{% if outgoing_links or incoming_links %}
<div class="linked-notes-grid">
{% for link in outgoing_links %}
<div class="linked-note-card">
<div class="linked-note-header">
<h4><a href="{{ url_for('view_note', slug=link.target_note.slug) }}">{{ link.target_note.title }}</a></h4>
<span class="link-type">→ {{ link.link_type }}</span>
</div>
<div class="linked-note-preview">
{{ link.target_note.get_preview()|safe }}
</div>
{% if note.can_user_edit(g.user) %}
<button class="remove-link-btn" data-target-id="{{ link.target_note_id }}">Remove Link</button>
{% endif %}
</div>
{% endfor %}
{% for link in incoming_links %}
<div class="linked-note-card">
<div class="linked-note-header">
<h4><a href="{{ url_for('view_note', slug=link.source_note.slug) }}">{{ link.source_note.title }}</a></h4>
<span class="link-type">← {{ link.link_type }}</span>
</div>
<div class="linked-note-preview">
{{ link.source_note.get_preview()|safe }}
</div>
{% if note.can_user_edit(g.user) %}
<button class="remove-link-btn" data-target-id="{{ link.source_note_id }}">Remove Link</button>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="no-links">No linked notes yet.</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Add Link Modal -->
{% if note.can_user_edit(g.user) %}
<div id="link-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Link to Another Note</h3>
<form id="link-form">
<div class="form-group">
<label for="target-note">Select Note:</label>
<select id="target-note" name="target_note_id" required>
<option value="">Choose a note...</option>
{% for other_note in linkable_notes %}
{% if other_note.id != note.id %}
<option value="{{ other_note.id }}">
{{ other_note.title }}
({{ other_note.visibility.value }})
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="link-type">Link Type:</label>
<select id="link-type" name="link_type">
<option value="related">Related</option>
<option value="references">References</option>
<option value="parent">Parent</option>
<option value="child">Child</option>
</select>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Add Link</button>
<button type="button" class="btn btn-secondary" id="cancel-link">Cancel</button>
</div>
</form>
</div>
</div>
{% endif %}
<style>
/* Note view specific styles */
.note-view-container {
max-width: 900px !important;
margin: 0 auto !important;
padding: 2rem !important;
}
.note-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid #e9ecef;
}
.note-title-section h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
line-height: 1.2;
}
.note-meta {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
font-size: 0.9rem;
color: #666;
}
.visibility-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.visibility-private {
background: #f8d7da;
color: #721c24;
}
.visibility-team {
background: #d1ecf1;
color: #0c5460;
}
.visibility-company {
background: #d4edda;
color: #155724;
}
.note-actions {
display: flex;
gap: 0.5rem;
}
.note-actions form {
margin: 0;
}
.note-associations {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.association-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.association-item:last-child {
margin-bottom: 0;
}
.association-label {
font-weight: 500;
margin-right: 1rem;
min-width: 80px;
}
.association-value {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag-badge {
background: #e9ecef;
color: #495057;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-decoration: none;
transition: background 0.2s ease;
}
.tag-badge:hover {
background: #dee2e6;
color: #333;
}
.note-content {
background: white;
padding: 2rem;
border: 1px solid #e9ecef;
border-radius: 8px;
min-height: 300px;
}
.markdown-content {
line-height: 1.6;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
}
.markdown-content h1:first-child {
margin-top: 0;
}
.markdown-content p {
margin-bottom: 1rem;
}
.markdown-content code {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #dee2e6;
padding-left: 1rem;
margin: 1rem 0;
color: #666;
}
.markdown-content ul, .markdown-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.markdown-content th, .markdown-content td {
border: 1px solid #dee2e6;
padding: 0.5rem;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.linked-notes-section {
margin-top: 3rem;
padding: 2rem;
background: #f8f9fa;
border-radius: 8px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
}
.linked-notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.linked-note-card {
background: white;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 6px;
position: relative;
}
.linked-note-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.linked-note-header h4 {
margin: 0;
font-size: 1.1rem;
}
.linked-note-header a {
color: #333;
text-decoration: none;
}
.linked-note-header a:hover {
color: var(--primary-color);
}
.link-type {
font-size: 0.8rem;
color: #666;
font-style: italic;
}
.linked-note-preview {
color: #666;
font-size: 0.9rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.remove-link-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
.linked-note-card:hover .remove-link-btn {
opacity: 1;
}
.no-links {
color: #999;
font-style: italic;
}
/* Modal styles */
.modal-content {
max-width: 500px;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Responsive design */
@media (max-width: 768px) {
.note-header {
flex-direction: column;
gap: 1rem;
}
.note-title-section h1 {
font-size: 2rem;
}
.note-meta {
font-size: 0.85rem;
}
.linked-notes-grid {
grid-template-columns: 1fr;
}
}
</style>
<script>
{% if note.can_user_edit(g.user) %}
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('link-modal');
const addLinkBtn = document.getElementById('add-link-btn');
const closeBtn = modal.querySelector('.close');
const cancelBtn = document.getElementById('cancel-link');
const linkForm = document.getElementById('link-form');
// Open modal
addLinkBtn.addEventListener('click', function() {
modal.style.display = 'block';
});
// Close modal
closeBtn.addEventListener('click', function() {
modal.style.display = 'none';
});
cancelBtn.addEventListener('click', function() {
modal.style.display = 'none';
});
window.addEventListener('click', function(event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
// Submit link form
linkForm.addEventListener('submit', function(e) {
e.preventDefault();
const targetNoteId = document.getElementById('target-note').value;
const linkType = document.getElementById('link-type').value;
fetch(`/api/notes/{{ note.id }}/link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target_note_id: parseInt(targetNoteId),
link_type: linkType
})
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Server error');
});
}
return response.json();
})
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error: ' + error.message);
});
});
// Remove link buttons
document.querySelectorAll('.remove-link-btn').forEach(btn => {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-target-id');
if (confirm('Are you sure you want to remove this link?')) {
fetch(`/api/notes/{{ note.id }}/link`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target_note_id: parseInt(targetId)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while removing the link');
});
}
});
});
});
{% endif %}
</script>
{% endblock %}

371
templates/notes_list.html Normal file
View File

@@ -0,0 +1,371 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container notes-list-container">
<div class="admin-header">
<h2>Notes</h2>
<div class="admin-actions">
<a href="{{ url_for('create_note') }}" class="btn btn-md btn-success">Create New Note</a>
</div>
</div>
<!-- Filter Section -->
<div class="notes-filter-section">
<form method="GET" action="{{ url_for('notes_list') }}" class="filter-form">
<div class="filter-row">
<div class="filter-group">
<label for="visibility">Visibility:</label>
<select name="visibility" id="visibility" onchange="this.form.submit()">
<option value="">All Notes</option>
<option value="private" {% if request.args.get('visibility') == 'private' %}selected{% endif %}>Private</option>
<option value="team" {% if request.args.get('visibility') == 'team' %}selected{% endif %}>Team</option>
<option value="company" {% if request.args.get('visibility') == 'company' %}selected{% endif %}>Company</option>
</select>
</div>
<div class="filter-group">
<label for="tag">Tag:</label>
<select name="tag" id="tag" onchange="this.form.submit()">
<option value="">All Tags</option>
{% for tag in all_tags %}
<option value="{{ tag }}" {% if request.args.get('tag') == tag %}selected{% endif %}>{{ tag }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="project_id">Project:</label>
<select name="project_id" id="project_id" onchange="this.form.submit()">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.code }} - {{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group search-group">
<label for="search">Search:</label>
<input type="text" name="search" id="search" placeholder="Search notes..."
value="{{ request.args.get('search', '') }}" class="search-input">
<button type="submit" class="btn btn-sm btn-primary">Search</button>
</div>
</div>
</form>
</div>
{% if notes %}
<div class="notes-grid">
{% for note in notes %}
<div class="note-card">
<div class="note-header">
<h3 class="note-title">
<a href="{{ url_for('view_note', slug=note.slug) }}">{{ note.title }}</a>
</h3>
<div class="note-meta">
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
{% if note.visibility.value == 'Private' %}🔒{% elif note.visibility.value == 'Team' %}👥{% else %}🏢{% endif %}
{{ note.visibility.value }}
</span>
{% if note.updated_at > note.created_at %}
<span class="note-date">Updated {{ note.updated_at|format_date }}</span>
{% else %}
<span class="note-date">Created {{ note.created_at|format_date }}</span>
{% endif %}
</div>
</div>
<div class="note-preview">
{{ note.get_preview()|safe }}
</div>
<div class="note-footer">
<div class="note-tags">
{% if note.tags %}
{% for tag in note.tags %}
<a href="{{ url_for('notes_list', tag=tag) }}" class="tag-badge">{{ tag }}</a>
{% endfor %}
{% endif %}
</div>
<div class="note-associations">
{% if note.project %}
<span class="association-badge project">
📁 {{ note.project.code }}
</span>
{% endif %}
{% if note.task %}
<span class="association-badge task">
✓ Task #{{ note.task.id }}
</span>
{% endif %}
{% if note.outgoing_links or note.incoming_links %}
<span class="association-badge links">
🔗 {{ (note.outgoing_links|length + note.incoming_links|length) }} link{% if (note.outgoing_links|length + note.incoming_links|length) != 1 %}s{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="note-actions">
<a href="{{ url_for('view_note', slug=note.slug) }}" class="btn btn-sm btn-primary">View</a>
{% if note.can_user_edit(g.user) %}
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-sm btn-info">Edit</a>
<form method="POST" action="{{ url_for('delete_note', slug=note.slug) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this note?')">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-data">
<p>No notes found. <a href="{{ url_for('create_note') }}">Create your first note</a>.</p>
</div>
{% endif %}
</div>
<style>
/* Notes list specific styles */
.notes-list-container {
max-width: none !important;
width: 100% !important;
padding: 1rem !important;
margin: 0 !important;
}
.notes-filter-section {
background: #f8f9fa;
padding: 1.5rem;
margin-bottom: 2rem;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.filter-form {
width: 100%;
}
.filter-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-group {
flex: 1;
min-width: 200px;
}
.filter-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.filter-group select,
.filter-group input {
width: 100%;
padding: 0.5rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 0.9rem;
}
.search-group {
flex: 2;
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.search-input {
flex: 1;
}
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.note-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
transition: box-shadow 0.2s ease;
display: flex;
flex-direction: column;
}
.note-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.note-header {
margin-bottom: 1rem;
}
.note-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.note-title a {
color: #333;
text-decoration: none;
}
.note-title a:hover {
color: var(--primary-color);
}
.note-meta {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.85rem;
color: #666;
}
.visibility-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.visibility-private {
background: #f8d7da;
color: #721c24;
}
.visibility-team {
background: #d1ecf1;
color: #0c5460;
}
.visibility-company {
background: #d4edda;
color: #155724;
}
.note-preview {
flex: 1;
margin-bottom: 1rem;
color: #555;
line-height: 1.6;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.note-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.note-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag-badge {
background: #e9ecef;
color: #495057;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-decoration: none;
transition: background 0.2s ease;
}
.tag-badge:hover {
background: #dee2e6;
color: #333;
}
.note-associations {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.association-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.association-badge.project {
background: #fff3cd;
color: #856404;
}
.association-badge.task {
background: #cce5ff;
color: #004085;
}
.association-badge.links {
background: #f5c6cb;
color: #721c24;
}
.note-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-start;
}
.note-actions form {
margin: 0;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.page-info {
color: #666;
font-size: 0.9rem;
}
/* Responsive design */
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
}
.filter-group {
min-width: 100%;
}
.search-group {
flex-direction: column;
align-items: stretch;
}
.notes-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}