Show folder tree for notes.
This commit is contained in:
381
app.py
381
app.py
@@ -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, Note, NoteLink, NoteVisibility
|
||||
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, NoteFolder
|
||||
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
|
||||
@@ -1616,6 +1616,7 @@ def notes_list():
|
||||
# Get filter parameters
|
||||
visibility_filter = request.args.get('visibility', 'all')
|
||||
tag_filter = request.args.get('tag')
|
||||
folder_filter = request.args.get('folder')
|
||||
search_query = request.args.get('search', request.args.get('q'))
|
||||
|
||||
# Base query - all notes in user's company
|
||||
@@ -1654,6 +1655,10 @@ def notes_list():
|
||||
if tag_filter:
|
||||
query = query.filter(Note.tags.like(f'%{tag_filter}%'))
|
||||
|
||||
# Apply folder filter
|
||||
if folder_filter:
|
||||
query = query.filter_by(folder=folder_filter)
|
||||
|
||||
# Apply search
|
||||
if search_query:
|
||||
query = query.filter(
|
||||
@@ -1672,16 +1677,66 @@ def notes_list():
|
||||
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 all unique folders for filter dropdown
|
||||
all_folders = set()
|
||||
|
||||
# Get folders from NoteFolder table
|
||||
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
||||
for folder in folder_records:
|
||||
all_folders.add(folder.path)
|
||||
|
||||
# Also get folders from notes (for backward compatibility)
|
||||
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
||||
for note in folder_notes:
|
||||
if note.folder:
|
||||
all_folders.add(note.folder)
|
||||
|
||||
# Get projects for filter
|
||||
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||
|
||||
# Build folder tree structure for sidebar
|
||||
folder_counts = {}
|
||||
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
|
||||
if note.folder and note.can_user_view(g.user):
|
||||
# Add this folder and all parent folders
|
||||
parts = note.folder.split('/')
|
||||
for i in range(len(parts)):
|
||||
folder_path = '/'.join(parts[:i+1])
|
||||
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
|
||||
|
||||
# Initialize counts for empty folders
|
||||
for folder_path in all_folders:
|
||||
if folder_path not in folder_counts:
|
||||
folder_counts[folder_path] = 0
|
||||
|
||||
# Build folder tree structure
|
||||
folder_tree = {}
|
||||
for folder in sorted(all_folders):
|
||||
parts = folder.split('/')
|
||||
current = folder_tree
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if i == len(parts) - 1:
|
||||
# Leaf folder
|
||||
current[folder] = {}
|
||||
else:
|
||||
# Navigate to parent
|
||||
parent_path = '/'.join(parts[:i+1])
|
||||
if parent_path not in current:
|
||||
current[parent_path] = {}
|
||||
current = current[parent_path]
|
||||
|
||||
return render_template('notes_list.html',
|
||||
title='Notes',
|
||||
notes=notes,
|
||||
visibility_filter=visibility_filter,
|
||||
tag_filter=tag_filter,
|
||||
folder_filter=folder_filter,
|
||||
search_query=search_query,
|
||||
all_tags=sorted(list(all_tags)),
|
||||
all_folders=sorted(list(all_folders)),
|
||||
folder_tree=folder_tree,
|
||||
folder_counts=folder_counts,
|
||||
projects=projects,
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
@@ -1695,6 +1750,7 @@ def create_note():
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
visibility = request.form.get('visibility', 'Private')
|
||||
folder = request.form.get('folder', '').strip()
|
||||
tags = request.form.get('tags', '').strip()
|
||||
project_id = request.form.get('project_id')
|
||||
task_id = request.form.get('task_id')
|
||||
@@ -1719,6 +1775,7 @@ def create_note():
|
||||
title=title,
|
||||
content=content,
|
||||
visibility=NoteVisibility[visibility.upper()], # Convert to uppercase for enum access
|
||||
folder=folder if folder else None,
|
||||
tags=','.join(tag_list) if tag_list else None,
|
||||
created_by_id=g.user.id,
|
||||
company_id=g.user.company_id
|
||||
@@ -1758,11 +1815,26 @@ def create_note():
|
||||
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||
tasks = []
|
||||
|
||||
# Get all existing folders for suggestions
|
||||
all_folders = set()
|
||||
|
||||
# Get folders from NoteFolder table
|
||||
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
||||
for folder in folder_records:
|
||||
all_folders.add(folder.path)
|
||||
|
||||
# Also get folders from notes (for backward compatibility)
|
||||
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
||||
for note in folder_notes:
|
||||
if note.folder:
|
||||
all_folders.add(note.folder)
|
||||
|
||||
return render_template('note_editor.html',
|
||||
title='New Note',
|
||||
note=None,
|
||||
projects=projects,
|
||||
tasks=tasks,
|
||||
all_folders=sorted(list(all_folders)),
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
|
||||
@@ -1826,6 +1898,7 @@ def edit_note(slug):
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
visibility = request.form.get('visibility', 'Private')
|
||||
folder = request.form.get('folder', '').strip()
|
||||
tags = request.form.get('tags', '').strip()
|
||||
project_id = request.form.get('project_id')
|
||||
task_id = request.form.get('task_id')
|
||||
@@ -1849,6 +1922,7 @@ def edit_note(slug):
|
||||
note.title = title
|
||||
note.content = content
|
||||
note.visibility = NoteVisibility[visibility.upper()] # Convert to uppercase for enum access
|
||||
note.folder = folder if folder else None
|
||||
note.tags = ','.join(tag_list) if tag_list else None
|
||||
|
||||
# Update team_id if visibility is Team
|
||||
@@ -1895,11 +1969,26 @@ def edit_note(slug):
|
||||
if note.project_id:
|
||||
tasks = Task.query.filter_by(project_id=note.project_id).all()
|
||||
|
||||
# Get all existing folders for suggestions
|
||||
all_folders = set()
|
||||
|
||||
# Get folders from NoteFolder table
|
||||
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
||||
for folder in folder_records:
|
||||
all_folders.add(folder.path)
|
||||
|
||||
# Also get folders from notes (for backward compatibility)
|
||||
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
||||
for n in folder_notes:
|
||||
if n.folder:
|
||||
all_folders.add(n.folder)
|
||||
|
||||
return render_template('note_editor.html',
|
||||
title=f'Edit: {note.title}',
|
||||
note=note,
|
||||
projects=projects,
|
||||
tasks=tasks,
|
||||
all_folders=sorted(list(all_folders)),
|
||||
NoteVisibility=NoteVisibility)
|
||||
|
||||
|
||||
@@ -1930,6 +2019,296 @@ def delete_note(slug):
|
||||
return redirect(url_for('view_note', slug=slug))
|
||||
|
||||
|
||||
@app.route('/notes/folders')
|
||||
@login_required
|
||||
@company_required
|
||||
def notes_folders():
|
||||
"""Manage note folders"""
|
||||
# Get all folders from NoteFolder table
|
||||
all_folders = set()
|
||||
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
||||
|
||||
for folder in folder_records:
|
||||
all_folders.add(folder.path)
|
||||
|
||||
# Also get folders from notes (for backward compatibility)
|
||||
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
||||
|
||||
folder_counts = {}
|
||||
for note in folder_notes:
|
||||
if note.folder and note.can_user_view(g.user):
|
||||
# Add this folder and all parent folders
|
||||
parts = note.folder.split('/')
|
||||
for i in range(len(parts)):
|
||||
folder_path = '/'.join(parts[:i+1])
|
||||
all_folders.add(folder_path)
|
||||
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
|
||||
|
||||
# Initialize counts for empty folders
|
||||
for folder_path in all_folders:
|
||||
if folder_path not in folder_counts:
|
||||
folder_counts[folder_path] = 0
|
||||
|
||||
# Build folder tree structure
|
||||
folder_tree = {}
|
||||
for folder in sorted(all_folders):
|
||||
parts = folder.split('/')
|
||||
current = folder_tree
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if i == len(parts) - 1:
|
||||
# Leaf folder
|
||||
current[folder] = {}
|
||||
else:
|
||||
# Navigate to parent
|
||||
parent_path = '/'.join(parts[:i+1])
|
||||
if parent_path not in current:
|
||||
current[parent_path] = {}
|
||||
current = current[parent_path]
|
||||
|
||||
return render_template('notes_folders.html',
|
||||
title='Note Folders',
|
||||
all_folders=sorted(list(all_folders)),
|
||||
folder_tree=folder_tree,
|
||||
folder_counts=folder_counts)
|
||||
|
||||
|
||||
@app.route('/api/notes/folder-details')
|
||||
@login_required
|
||||
@company_required
|
||||
def api_folder_details():
|
||||
"""Get details about a specific folder"""
|
||||
folder_path = request.args.get('path', '')
|
||||
|
||||
if not folder_path:
|
||||
return jsonify({'error': 'Folder path required'}), 400
|
||||
|
||||
# Get notes in this folder
|
||||
notes = Note.query.filter_by(
|
||||
company_id=g.user.company_id,
|
||||
folder=folder_path,
|
||||
is_archived=False
|
||||
).all()
|
||||
|
||||
# Filter by visibility
|
||||
visible_notes = [n for n in notes if n.can_user_view(g.user)]
|
||||
|
||||
# Get subfolders
|
||||
all_folders = set()
|
||||
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(
|
||||
Note.folder.like(f'{folder_path}/%')
|
||||
).all()
|
||||
|
||||
for note in folder_notes:
|
||||
if note.folder and note.can_user_view(g.user):
|
||||
# Get immediate subfolder
|
||||
subfolder = note.folder[len(folder_path)+1:]
|
||||
if '/' in subfolder:
|
||||
subfolder = subfolder.split('/')[0]
|
||||
all_folders.add(subfolder)
|
||||
|
||||
# Get recent notes (last 5)
|
||||
recent_notes = sorted(visible_notes, key=lambda n: n.updated_at, reverse=True)[:5]
|
||||
|
||||
return jsonify({
|
||||
'name': folder_path.split('/')[-1],
|
||||
'path': folder_path,
|
||||
'note_count': len(visible_notes),
|
||||
'subfolder_count': len(all_folders),
|
||||
'recent_notes': [
|
||||
{
|
||||
'title': n.title,
|
||||
'slug': n.slug,
|
||||
'updated_at': n.updated_at.strftime('%Y-%m-%d %H:%M')
|
||||
} for n in recent_notes
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/notes/folders', methods=['POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
def api_create_folder():
|
||||
"""Create a new folder"""
|
||||
data = request.get_json()
|
||||
folder_name = data.get('name', '').strip()
|
||||
parent_folder = data.get('parent', '').strip()
|
||||
|
||||
if not folder_name:
|
||||
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
|
||||
|
||||
# Validate folder name (no special characters except dash and underscore)
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_\- ]+$', folder_name):
|
||||
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
|
||||
|
||||
# Create full path
|
||||
full_path = f"{parent_folder}/{folder_name}" if parent_folder else folder_name
|
||||
|
||||
# Check if folder already exists
|
||||
existing_folder = NoteFolder.query.filter_by(
|
||||
company_id=g.user.company_id,
|
||||
path=full_path
|
||||
).first()
|
||||
|
||||
if existing_folder:
|
||||
return jsonify({'success': False, 'message': 'Folder already exists'}), 400
|
||||
|
||||
# Create the folder
|
||||
try:
|
||||
folder = NoteFolder(
|
||||
name=folder_name,
|
||||
path=full_path,
|
||||
parent_path=parent_folder if parent_folder else None,
|
||||
description=data.get('description', ''),
|
||||
created_by_id=g.user.id,
|
||||
company_id=g.user.company_id
|
||||
)
|
||||
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Folder created successfully',
|
||||
'folder': {
|
||||
'name': folder_name,
|
||||
'path': full_path
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error creating folder: {str(e)}")
|
||||
return jsonify({'success': False, 'message': 'Error creating folder'}), 500
|
||||
|
||||
|
||||
@app.route('/api/notes/folders', methods=['PUT'])
|
||||
@login_required
|
||||
@company_required
|
||||
def api_rename_folder():
|
||||
"""Rename an existing folder"""
|
||||
data = request.get_json()
|
||||
old_path = data.get('old_path', '').strip()
|
||||
new_name = data.get('new_name', '').strip()
|
||||
|
||||
if not old_path or not new_name:
|
||||
return jsonify({'success': False, 'message': 'Old path and new name are required'}), 400
|
||||
|
||||
# Validate folder name
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_\- ]+$', new_name):
|
||||
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
|
||||
|
||||
# Build new path
|
||||
path_parts = old_path.split('/')
|
||||
path_parts[-1] = new_name
|
||||
new_path = '/'.join(path_parts)
|
||||
|
||||
# Update all notes in this folder and subfolders
|
||||
notes_to_update = Note.query.filter(
|
||||
Note.company_id == g.user.company_id,
|
||||
db.or_(
|
||||
Note.folder == old_path,
|
||||
Note.folder.like(f'{old_path}/%')
|
||||
)
|
||||
).all()
|
||||
|
||||
# Check permissions for all notes
|
||||
for note in notes_to_update:
|
||||
if not note.can_user_edit(g.user):
|
||||
return jsonify({'success': False, 'message': 'You do not have permission to modify all notes in this folder'}), 403
|
||||
|
||||
# Update folder paths
|
||||
try:
|
||||
for note in notes_to_update:
|
||||
if note.folder == old_path:
|
||||
note.folder = new_path
|
||||
else:
|
||||
# Update subfolder path
|
||||
note.folder = new_path + note.folder[len(old_path):]
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Renamed folder to {new_name}',
|
||||
'updated_count': len(notes_to_update)
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error renaming folder: {str(e)}")
|
||||
return jsonify({'success': False, 'message': 'Error renaming folder'}), 500
|
||||
|
||||
|
||||
@app.route('/api/notes/folders', methods=['DELETE'])
|
||||
@login_required
|
||||
@company_required
|
||||
def api_delete_folder():
|
||||
"""Delete an empty folder"""
|
||||
folder_path = request.args.get('path', '').strip()
|
||||
|
||||
if not folder_path:
|
||||
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
|
||||
|
||||
# Check if folder has any notes
|
||||
notes_in_folder = Note.query.filter_by(
|
||||
company_id=g.user.company_id,
|
||||
folder=folder_path,
|
||||
is_archived=False
|
||||
).all()
|
||||
|
||||
if notes_in_folder:
|
||||
return jsonify({'success': False, 'message': 'Cannot delete folder that contains notes'}), 400
|
||||
|
||||
# Check if folder has subfolders with notes
|
||||
notes_in_subfolders = Note.query.filter(
|
||||
Note.company_id == g.user.company_id,
|
||||
Note.folder.like(f'{folder_path}/%'),
|
||||
Note.is_archived == False
|
||||
).first()
|
||||
|
||||
if notes_in_subfolders:
|
||||
return jsonify({'success': False, 'message': 'Cannot delete folder that contains subfolders with notes'}), 400
|
||||
|
||||
# Since we don't have a separate folders table, we just return success
|
||||
# The folder will disappear from the UI when there are no notes in it
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Folder deleted successfully'
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/notes/<slug>/folder', methods=['PUT'])
|
||||
@login_required
|
||||
@company_required
|
||||
def update_note_folder(slug):
|
||||
"""Update a note's folder via drag and drop"""
|
||||
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
||||
|
||||
# Check permissions
|
||||
if not note.can_user_edit(g.user):
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
folder_path = data.get('folder', '').strip()
|
||||
|
||||
try:
|
||||
# Update the note's folder
|
||||
note.folder = folder_path if folder_path else None
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Note moved successfully',
|
||||
'folder': folder_path
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error updating note folder: {str(e)}")
|
||||
return jsonify({'success': False, 'message': 'Error updating note folder'}), 500
|
||||
|
||||
|
||||
@app.route('/api/notes/<int:note_id>/link', methods=['POST'])
|
||||
@login_required
|
||||
@company_required
|
||||
|
||||
120
migrate_db.py
120
migrate_db.py
@@ -1280,7 +1280,21 @@ def migrate_postgresql_schema():
|
||||
WHERE table_name = 'note'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
if result.fetchone():
|
||||
# Table exists, check for folder column
|
||||
result = db.session.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'note' AND column_name = 'folder'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding folder column to note table...")
|
||||
db.session.execute(text("ALTER TABLE note ADD COLUMN folder VARCHAR(100)"))
|
||||
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)"))
|
||||
db.session.commit()
|
||||
print("Folder column added successfully!")
|
||||
else:
|
||||
print("Creating note and note_link tables...")
|
||||
|
||||
# Create NoteVisibility enum type
|
||||
@@ -1299,6 +1313,7 @@ def migrate_postgresql_schema():
|
||||
content TEXT NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
visibility notevisibility NOT NULL DEFAULT 'Private',
|
||||
folder VARCHAR(100),
|
||||
company_id INTEGER NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
@@ -1327,6 +1342,31 @@ def migrate_postgresql_schema():
|
||||
)
|
||||
"""))
|
||||
|
||||
# Check if note_folder table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'note_folder'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating note_folder table...")
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE note_folder (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
parent_path VARCHAR(500),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
CONSTRAINT uq_folder_path_company UNIQUE (path, company_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 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)"))
|
||||
@@ -1334,11 +1374,23 @@ def migrate_postgresql_schema():
|
||||
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_archived ON note(is_archived)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)"))
|
||||
db.session.execute(text("CREATE INDEX idx_note_folder ON note(folder)"))
|
||||
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)"))
|
||||
|
||||
# Create indexes for note_folder if table was created
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'note_folder'
|
||||
"""))
|
||||
if result.fetchone():
|
||||
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id)"))
|
||||
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path)"))
|
||||
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id)"))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print("PostgreSQL schema migration completed successfully!")
|
||||
@@ -1568,7 +1620,46 @@ def migrate_notes_system(db_file=None):
|
||||
# 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.")
|
||||
print("Note table already exists. Checking for updates...")
|
||||
|
||||
# Check if folder column exists
|
||||
cursor.execute("PRAGMA table_info(note)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'folder' not in columns:
|
||||
print("Adding folder column to note table...")
|
||||
cursor.execute("ALTER TABLE note ADD COLUMN folder VARCHAR(100)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)")
|
||||
conn.commit()
|
||||
print("Folder column added successfully!")
|
||||
|
||||
# Check if note_folder table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_folder'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating note_folder table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE note_folder (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
parent_path VARCHAR(500),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (created_by_id) REFERENCES user(id),
|
||||
FOREIGN KEY (company_id) REFERENCES company(id),
|
||||
UNIQUE(path, company_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for note_folder
|
||||
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
|
||||
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
|
||||
conn.commit()
|
||||
print("Note folder table created successfully!")
|
||||
|
||||
return True
|
||||
|
||||
print("Creating Notes system tables...")
|
||||
@@ -1581,6 +1672,7 @@ def migrate_notes_system(db_file=None):
|
||||
content TEXT NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'Private',
|
||||
folder VARCHAR(100),
|
||||
company_id INTEGER NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
@@ -1626,6 +1718,28 @@ def migrate_notes_system(db_file=None):
|
||||
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)")
|
||||
|
||||
# Create note_folder table
|
||||
cursor.execute("""
|
||||
CREATE TABLE note_folder (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
parent_path VARCHAR(500),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (created_by_id) REFERENCES user(id),
|
||||
FOREIGN KEY (company_id) REFERENCES company(id),
|
||||
UNIQUE(path, company_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for note_folder
|
||||
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
|
||||
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
|
||||
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
|
||||
|
||||
conn.commit()
|
||||
print("Notes system migration completed successfully!")
|
||||
return True
|
||||
|
||||
29
models.py
29
models.py
@@ -1261,6 +1261,9 @@ class Note(db.Model):
|
||||
# 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)
|
||||
@@ -1418,3 +1421,29 @@ class NoteLink(db.Model):
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NoteLink {self.source_note_id} -> {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'<NoteFolder {self.path}>'
|
||||
@@ -59,6 +59,19 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="folder">Folder</label>
|
||||
<input type="text" id="folder" name="folder" class="form-control"
|
||||
placeholder="e.g., Work/Projects or Personal"
|
||||
value="{{ note.folder if note and note.folder else '' }}"
|
||||
list="folder-suggestions">
|
||||
<datalist id="folder-suggestions">
|
||||
{% for folder in all_folders %}
|
||||
<option value="{{ folder }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-control"
|
||||
|
||||
538
templates/notes_folders.html
Normal file
538
templates/notes_folders.html
Normal file
@@ -0,0 +1,538 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container notes-folders-container">
|
||||
<div class="admin-header">
|
||||
<h2>Note Folders</h2>
|
||||
<div class="admin-actions">
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="showCreateFolderModal()">
|
||||
<span>📁</span> Create Folder
|
||||
</button>
|
||||
<a href="{{ url_for('notes_list') }}" class="btn btn-sm btn-secondary">Back to Notes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="folders-layout">
|
||||
<!-- Folder Tree -->
|
||||
<div class="folder-tree-panel">
|
||||
<h3>Folder Structure</h3>
|
||||
<div class="folder-tree" id="folder-tree">
|
||||
{{ render_folder_tree(folder_tree)|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Details -->
|
||||
<div class="folder-details-panel">
|
||||
<div id="folder-info">
|
||||
<p class="text-muted">Select a folder to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Folder Modal -->
|
||||
<div class="modal" id="folderModal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">Create New Folder</h3>
|
||||
<button type="button" class="close-btn" onclick="closeFolderModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="folderForm">
|
||||
<div class="form-group">
|
||||
<label for="folderName">Folder Name</label>
|
||||
<input type="text" id="folderName" name="name" class="form-control" required
|
||||
placeholder="e.g., Projects, Meeting Notes">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="parentFolder">Parent Folder</label>
|
||||
<select id="parentFolder" name="parent" class="form-control">
|
||||
<option value="">Root (Top Level)</option>
|
||||
{% for folder in all_folders %}
|
||||
<option value="{{ folder }}">{{ folder }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="folderDescription">Description (Optional)</label>
|
||||
<textarea id="folderDescription" name="description" class="form-control"
|
||||
rows="3" placeholder="What kind of notes will go in this folder?"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeFolderModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveFolder()">Save Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notes-folders-container {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 1rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.folders-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 2rem;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.folder-tree-panel,
|
||||
.folder-details-panel {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.folder-tree-panel h3,
|
||||
.folder-details-panel h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Folder Tree Styles */
|
||||
.folder-tree {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
position: relative;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.folder-item.has-children > .folder-content::before {
|
||||
content: "▶";
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-item.has-children.expanded > .folder-content::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-left: 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.folder-content:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.folder-content.selected {
|
||||
background: #e3f2fd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.folder-count {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
margin-left: 1.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-item.expanded > .folder-children {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Folder Details */
|
||||
.folder-details {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.folder-details h4 {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
font-family: monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.folder-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.notes-preview {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.notes-preview h5 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note-preview-item {
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.note-preview-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.note-preview-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.note-preview-date {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.folders-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.folder-tree-panel {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let selectedFolder = null;
|
||||
|
||||
function selectFolder(folderPath) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.folder-content').forEach(el => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked folder
|
||||
event.currentTarget.classList.add('selected');
|
||||
selectedFolder = folderPath;
|
||||
|
||||
// Load folder details
|
||||
loadFolderDetails(folderPath);
|
||||
}
|
||||
|
||||
function toggleFolder(event, folderPath) {
|
||||
event.stopPropagation();
|
||||
const folderItem = event.currentTarget.closest('.folder-item');
|
||||
folderItem.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
function loadFolderDetails(folderPath) {
|
||||
fetch(`/api/notes/folder-details?path=${encodeURIComponent(folderPath)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const detailsHtml = `
|
||||
<div class="folder-details">
|
||||
<h4><span class="folder-icon">📁</span> ${data.name}</h4>
|
||||
<div class="folder-path">${data.path}</div>
|
||||
|
||||
<div class="folder-stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">${data.note_count}</div>
|
||||
<div class="stat-label">Notes</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">${data.subfolder_count}</div>
|
||||
<div class="stat-label">Subfolders</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="folder-actions">
|
||||
<a href="/notes?folder=${encodeURIComponent(data.path)}" class="btn btn-sm btn-primary">
|
||||
View Notes
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-info" onclick="editFolder('${data.path}')">
|
||||
Rename
|
||||
</button>
|
||||
${data.note_count === 0 && data.subfolder_count === 0 ?
|
||||
`<button type="button" class="btn btn-sm btn-danger" onclick="deleteFolder('${data.path}')">
|
||||
Delete
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
||||
${data.recent_notes.length > 0 ? `
|
||||
<div class="notes-preview">
|
||||
<h5>Recent Notes</h5>
|
||||
${data.recent_notes.map(note => `
|
||||
<div class="note-preview-item">
|
||||
<a href="/notes/${note.slug}" class="note-preview-title">${note.title}</a>
|
||||
<div class="note-preview-date">${note.updated_at}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('folder-info').innerHTML = detailsHtml;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading folder details:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateFolderModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create New Folder';
|
||||
document.getElementById('folderForm').reset();
|
||||
document.getElementById('folderModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeFolderModal() {
|
||||
document.getElementById('folderModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveFolder() {
|
||||
const formData = new FormData(document.getElementById('folderForm'));
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
parent: formData.get('parent'),
|
||||
description: formData.get('description')
|
||||
};
|
||||
|
||||
// Check if we're editing or creating
|
||||
const modalTitle = document.getElementById('modalTitle').textContent;
|
||||
const isEditing = modalTitle.includes('Edit');
|
||||
|
||||
if (isEditing && selectedFolder) {
|
||||
// Rename folder
|
||||
fetch('/api/notes/folders', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_path: selectedFolder,
|
||||
new_name: data.name
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error: ' + result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error renaming folder');
|
||||
});
|
||||
} else {
|
||||
// Create new folder
|
||||
fetch('/api/notes/folders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error: ' + result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error creating folder');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function editFolder(folderPath) {
|
||||
const parts = folderPath.split('/');
|
||||
const folderName = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
|
||||
document.getElementById('modalTitle').textContent = 'Edit Folder';
|
||||
document.getElementById('folderName').value = folderName;
|
||||
document.getElementById('parentFolder').value = parentPath;
|
||||
document.getElementById('folderModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function deleteFolder(folderPath) {
|
||||
if (confirm(`Are you sure you want to delete the folder "${folderPath}"? This cannot be undone.`)) {
|
||||
fetch(`/api/notes/folders?path=${encodeURIComponent(folderPath)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error: ' + result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting folder');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('folderModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeFolderModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% macro render_folder_tree(tree, level=0) %}
|
||||
{% for folder, children in tree.items() %}
|
||||
<div class="folder-item {% if children %}has-children{% endif %}" data-folder="{{ folder }}">
|
||||
<div class="folder-content" onclick="selectFolder('{{ folder }}')">
|
||||
{% if children %}
|
||||
<span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;">▶</span>
|
||||
{% endif %}
|
||||
<span class="folder-icon">📁</span>
|
||||
<span class="folder-name">{{ folder.split('/')[-1] }}</span>
|
||||
<span class="folder-count">({{ folder_counts.get(folder, 0) }})</span>
|
||||
</div>
|
||||
{% if children %}
|
||||
<div class="folder-children">
|
||||
{{ render_folder_tree(children, level + 1)|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user