From be370708a70d208fc899fa17f9832c6e8205028c Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 6 Jul 2025 21:18:19 +0200 Subject: [PATCH] Add tags tree for notes. --- app.py | 59 ++- migrations/add_folder_to_notes.sql | 5 + migrations/add_note_folder_table.sql | 17 + templates/note_editor.html | 2 +- templates/notes_list.html | 555 ++++++++++++++++++++++++--- 5 files changed, 571 insertions(+), 67 deletions(-) create mode 100644 migrations/add_folder_to_notes.sql create mode 100644 migrations/add_note_folder_table.sql diff --git a/app.py b/app.py index 62568c3..d5d6013 100644 --- a/app.py +++ b/app.py @@ -1672,10 +1672,21 @@ def notes_list(): # 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 + # Get all unique tags for filter dropdown and count them all_tags = set() + tag_counts = {} + visibility_counts = {'private': 0, 'team': 0, 'company': 0} + for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all(): - all_tags.update(note.get_tags_list()) + if note.can_user_view(g.user): + # Count tags + note_tags = note.get_tags_list() + all_tags.update(note_tags) + for tag in note_tags: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # Count visibility + visibility_counts[note.visibility.value.lower()] = visibility_counts.get(note.visibility.value.lower(), 0) + 1 # Get all unique folders for filter dropdown all_folders = set() @@ -1737,6 +1748,8 @@ def notes_list(): all_folders=sorted(list(all_folders)), folder_tree=folder_tree, folder_counts=folder_counts, + tag_counts=tag_counts, + visibility_counts=visibility_counts, projects=projects, NoteVisibility=NoteVisibility) @@ -2309,6 +2322,48 @@ def update_note_folder(slug): return jsonify({'success': False, 'message': 'Error updating note folder'}), 500 +@app.route('/api/notes//tags', methods=['POST']) +@login_required +@company_required +def add_tags_to_note(note_id): + """Add tags to a note""" + note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404() + + # Check permissions + if not note.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + data = request.get_json() + new_tags = data.get('tags', '').strip() + + if not new_tags: + return jsonify({'success': False, 'message': 'No tags provided'}), 400 + + try: + # Get existing tags + existing_tags = note.get_tags_list() + + # Parse new tags + new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()] + + # Merge tags (avoid duplicates) + all_tags = list(set(existing_tags + new_tag_list)) + + # Update note + note.set_tags_list(all_tags) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Tags added successfully', + 'tags': note.tags + }) + except Exception as e: + db.session.rollback() + logger.error(f"Error adding tags to note: {str(e)}") + return jsonify({'success': False, 'message': 'Error adding tags'}), 500 + + @app.route('/api/notes//link', methods=['POST']) @login_required @company_required diff --git a/migrations/add_folder_to_notes.sql b/migrations/add_folder_to_notes.sql new file mode 100644 index 0000000..e7bf3fb --- /dev/null +++ b/migrations/add_folder_to_notes.sql @@ -0,0 +1,5 @@ +-- Add folder column to notes table +ALTER TABLE note ADD COLUMN IF NOT EXISTS folder VARCHAR(100); + +-- Create an index on folder for faster filtering +CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder) WHERE folder IS NOT NULL; \ No newline at end of file diff --git a/migrations/add_note_folder_table.sql b/migrations/add_note_folder_table.sql new file mode 100644 index 0000000..4b1687a --- /dev/null +++ b/migrations/add_note_folder_table.sql @@ -0,0 +1,17 @@ +-- Create note_folder table for tracking folders independently of notes +CREATE TABLE IF NOT EXISTS 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 REFERENCES "user"(id), + company_id INTEGER NOT NULL REFERENCES company(id), + CONSTRAINT uq_folder_path_company UNIQUE (path, company_id) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id); +CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path); +CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id); \ No newline at end of file diff --git a/templates/note_editor.html b/templates/note_editor.html index 05f7555..b3ded1c 100644 --- a/templates/note_editor.html +++ b/templates/note_editor.html @@ -76,7 +76,7 @@ + value="{{ note.tags if note and note.tags else '' }}"> diff --git a/templates/notes_list.html b/templates/notes_list.html index 3e6f4b6..bebba42 100644 --- a/templates/notes_list.html +++ b/templates/notes_list.html @@ -31,7 +31,7 @@
-
+
🏠 All Notes ({{ notes|length }}) @@ -39,6 +39,73 @@
{{ render_folder_tree(folder_tree)|safe }}
+ + +
+
+
+ +

Tags

+ ({{ all_tags|length }}) +
+ +
+
+ {% if tag_filter %} +
+ ✖️ + Clear filter +
+ {% endif %} + {% if all_tags %} + {% for tag in all_tags %} +
+ 🏷️ + {{ tag }} + ({{ tag_counts.get(tag, 0) }}) +
+ {% endfor %} + {% else %} +
No tags yet
+ {% endif %} +
+
+ + +
+
+
+ +

Visibility

+
+
+
+
+ 👁️ + All Notes + ({{ notes|length }}) +
+
+ 🔒 + Private + ({{ visibility_counts.get('private', 0) }}) +
+
+ 👥 + Team + ({{ visibility_counts.get('team', 0) }}) +
+
+ 🏢 + Company + ({{ visibility_counts.get('company', 0) }}) +
+
+
@@ -46,43 +113,41 @@
-
+ + + + + +
-
- - -
-
- - -
-
- - -
- -
+ + {% if folder_filter or tag_filter or visibility_filter %} +
+ Active filters: + {% if folder_filter %} + + 📁 {{ folder_filter }} × + + {% endif %} + {% if tag_filter %} + + 🏷️ {{ tag_filter }} × + + {% endif %} + {% if visibility_filter %} + + {% if visibility_filter == 'private' %}🔒{% elif visibility_filter == 'team' %}👥{% else %}🏢{% endif %} + {{ visibility_filter|title }} × + + {% endif %} + +
+ {% endif %}
@@ -349,6 +414,11 @@ background: #f8f9fa; } +.folder-content.active { + background: #e3f2fd; + font-weight: 500; +} + .folder-content.drag-over { background: #e3f2fd; border: 2px dashed #2196F3; @@ -382,6 +452,240 @@ display: block; } +/* Tags section styles */ +.tags-section { + margin-top: 2rem; + border-top: 1px solid #dee2e6; + padding-top: 1rem; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.section-title { + display: flex; + align-items: center; + cursor: pointer; + padding: 0.5rem; + margin: -0.5rem; + border-radius: 4px; + transition: background 0.2s; + flex: 1; +} + +.section-title:hover { + background: #f8f9fa; +} + +.section-title h3 { + margin: 0; + font-size: 1.1rem; + color: #333; + flex: 1; + margin-left: 0.5rem; +} + +.section-toggle { + font-size: 0.8rem; + transition: transform 0.2s; +} + +.section-toggle.collapsed { + transform: rotate(-90deg); +} + +.tags-count { + font-size: 0.85rem; + color: #666; +} + +.tags-list { + margin-top: 0.5rem; + transition: max-height 0.3s ease-out; + overflow: hidden; +} + +.tags-list.collapsed { + max-height: 0; +} + +.tag-item { + display: flex; + align-items: center; + padding: 0.4rem 0.75rem; + margin: 0.25rem 0; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.tag-item:hover { + background: #f8f9fa; +} + +.tag-item.active { + background: #e3f2fd; + font-weight: 500; +} + +.tag-item.clear-filter { + color: #dc3545; + font-size: 0.85rem; +} + +.tag-icon { + margin-right: 0.5rem; + font-size: 0.9rem; +} + +.tag-name { + flex: 1; + font-size: 0.9rem; +} + +.tag-count { + font-size: 0.85rem; + color: #666; + margin-left: 0.5rem; +} + +.no-tags { + padding: 1rem; + text-align: center; + color: #999; + font-size: 0.9rem; +} + +/* Visibility section styles */ +.visibility-section { + margin-top: 2rem; + border-top: 1px solid #dee2e6; + padding-top: 1rem; +} + +.visibility-list { + margin-top: 0.5rem; + transition: max-height 0.3s ease-out; + overflow: hidden; +} + +.visibility-list.collapsed { + max-height: 0; +} + +.visibility-item { + display: flex; + align-items: center; + padding: 0.4rem 0.75rem; + margin: 0.25rem 0; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.visibility-item:hover { + background: #f8f9fa; +} + +.visibility-item.active { + background: #e3f2fd; + font-weight: 500; +} + +.visibility-icon { + margin-right: 0.5rem; + font-size: 0.9rem; +} + +.visibility-name { + flex: 1; + font-size: 0.9rem; +} + +.visibility-count { + font-size: 0.85rem; + color: #666; + margin-left: 0.5rem; +} + +/* Simplified filter section */ +.notes-filter-section { + background: #f8f9fa; + padding: 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.filter-row { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.search-group { + flex: 1; + display: flex; + gap: 0.5rem; + min-width: 300px; +} + +.search-input { + flex: 1; + padding: 0.5rem 1rem; + border: 2px solid #e9ecef; + border-radius: 6px; + font-size: 0.95rem; +} + +.search-input:focus { + outline: none; + border-color: var(--primary-color); +} + +.active-filters { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.filter-label { + font-size: 0.85rem; + color: #666; +} + +.filter-badge { + background: #e3f2fd; + color: #333; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.filter-badge:hover { + background: #bbdefb; +} + +.filter-badge .remove { + font-weight: bold; + color: #666; +} + +.btn-xs { + padding: 0.2rem 0.5rem; + font-size: 0.75rem; +} + /* Drag and drop styles */ .note-row.dragging, .note-card.dragging { @@ -441,37 +745,7 @@ 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; -} /* Table View Styles */ .notes-table { @@ -847,6 +1121,13 @@ border-radius: 4px; font-size: 0.95rem; } + +.form-text { + display: block; + margin-top: 0.25rem; + font-size: 0.85rem; + color: #6c757d; +} {% endblock %} @@ -1094,7 +1521,7 @@ function createFolder() { {% macro render_folder_tree(tree, level=0) %} {% for folder, children in tree.items() %}
-
+
{% if children %} {% endif %}