Loading...
-diff --git a/blueprints/api.py b/blueprints/api.py index 30c4080..b327446 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -6,6 +6,7 @@ from models.note import Note from models.collaborator import Collaborator from models.user import User from models.favorite import Favorite +from sqlalchemy import func import csv from io import StringIO @@ -68,13 +69,23 @@ def get_notepads(): query = query.filter_by(is_public=True) pads = query.order_by(Notepad.name).all() + + # Optimize note count query (O(1) database hit instead of O(N)) + note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) + + # Optimize write permissions check + collab_write_pad_ids = set() + if user_id: + collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()} + results = [] for pad in pads: - p_dict = pad.to_dict() + note_count = note_counts.get(pad.id, 0) + p_dict = pad.to_dict(note_count=note_count) if user_id: if pad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad.id) - p_dict['can_write'] = check_pad_write_permission(pad, user_id) + p_dict['can_write'] = (pad.user_id == user_id) or (pad.id in collab_write_pad_ids) else: p_dict['can_write'] = False results.append(p_dict) @@ -156,7 +167,6 @@ def get_note(note_id): n_dict['share_token'] = get_share_token('note', note_id) return jsonify(n_dict) - @api_bp.route('/notes/mine', methods=['GET']) def get_my_all_notes(): user_id = session.get('user_id') @@ -164,10 +174,14 @@ def get_my_all_notes(): return jsonify({'error': 'Unauthorized'}), 401 notes = Note.query.join(Notepad).filter(Notepad.user_id == user_id, Note.is_hidden == False).all() + + # Eagerly load note counts to avoid N+1 queries when calling pad.to_dict() + note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) + results = [] for note in notes: n_dict = note.to_dict() - n_dict['notepad'] = note.notepad.to_dict() + n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0)) n_dict['owner_id'] = user_id n_dict['is_public'] = note.notepad.is_public n_dict['share_token'] = get_share_token('note', note.id) diff --git a/blueprints/search.py b/blueprints/search.py index f4f54cc..19e261c 100644 --- a/blueprints/search.py +++ b/blueprints/search.py @@ -1,9 +1,10 @@ from flask import Blueprint, request, jsonify, session -from sqlalchemy import or_ +from sqlalchemy import or_, func from extensions import db from models.note import Note from models.notepad import Notepad from models.favorite import Favorite +from models.collaborator import Collaborator from .api import check_pad_write_permission import urllib.request import urllib.parse @@ -45,13 +46,19 @@ def search_notes(): favs = Favorite.query.filter_by(user_id=user_id).all() fav_ids = {f.notepad_id for f in favs} + # Eagerly fetch note counts and permissions to prevent N+1 queries + note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all()) + collab_write_pad_ids = set() + if user_id: + collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()} + # Construct response with extra info for frontend (owner_id, is_public) output = [] for note, pad in results: n_dict = note.to_dict() - p_dict = pad.to_dict() + p_dict = pad.to_dict(note_count=note_counts.get(pad.id, 0)) p_dict['is_favorite'] = pad.id in fav_ids - p_dict['can_write'] = check_pad_write_permission(pad, user_id) + p_dict['can_write'] = (pad.user_id == user_id) or (pad.id in collab_write_pad_ids) n_dict['notepad'] = p_dict n_dict['owner_id'] = pad.user_id # Keep for map marker compatibility diff --git a/models/notepad.py b/models/notepad.py index b7d14b3..a0d1fff 100644 --- a/models/notepad.py +++ b/models/notepad.py @@ -15,12 +15,12 @@ class Notepad(db.Model): def __init__(self, **kwargs): super().__init__(**kwargs) - def to_dict(self): + def to_dict(self, note_count=None): return { 'id': self.id, 'name': self.name, 'is_public': self.is_public, 'owner_id': self.user_id, 'color': self.color, - 'note_count': self.notes.filter_by(is_hidden=False).count() + 'note_count': note_count if note_count is not None else self.notes.filter_by(is_hidden=False).count() } \ No newline at end of file diff --git a/refactor.py b/refactor.py deleted file mode 100644 index a04b328..0000000 --- a/refactor.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -import re - -base_dir = r'z:\source\forgejo\spotnotes.app\static\assets\js' -index_path = os.path.join(base_dir, 'index.js') - -with open(index_path, 'r', encoding='utf-8') as f: - content = f.read() - -# Remove the blocks that were moved to map.js and sidebar.js -# initMap -content = re.sub(r'function initMap\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# initSidebar -content = re.sub(r'function initSidebar\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# renderMapMarkers -content = re.sub(r'function renderMapMarkers\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# stopTracking -content = re.sub(r'function stopTracking\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# updateTrackingUI -content = re.sub(r'function updateTrackingUI\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# renderNotepadList -content = re.sub(r'function renderNotepadList\(pads, containerId, isFavorite\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# setupPublicToggle -content = re.sub(r'function setupPublicToggle\(\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) -# toggleSection -content = re.sub(r'function toggleSection\(event\).*?^}', '', content, flags=re.MULTILINE | re.DOTALL) - - -# Now, wrap the whole thing or replace globals -# It's easier to rewrite index.js using a structured output or targeted regexes. -# Let's replace the variable declarations: -globals_to_replace = [ - 'map', 'activeNotes', 'userLocation', 'activePadName', 'activePadId', - 'activePadIsPublic', 'activePadOwnerId', 'activePadCanWrite', 'currentUser', - 'isTracking', 'userMarker', 'notifiedNoteIds', 'isLoggedIn', 'currentMarkers', 'topZIndex' -] - -# Remove the var declarations -for var in globals_to_replace: - content = re.sub(r'let\s+' + var + r'\s*=[^;]+;\n?', '', content) - -# Prepend window.SpotNotes. to usages -for var in globals_to_replace: - # Use negative lookbehind/lookahead to avoid matching substring or properties if any - content = re.sub(r'(? - -
No notes yet.
'; - } - - notes.forEach(note => { - const item = document.createElement('div'); - item.className = 'sidebar-note-item'; - - let actionBtns = ''; - if (window.SpotNotes.activePadCanWrite) { - const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\\\'").replace(/"/g, '"'); - const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\\\'").replace(/"/g, '"').replace(/\\n/g, '\\\\n'); - actionBtns = ` - - - `; - } else if (window.SpotNotes.activePadIsPublic) { - actionBtns = ``; - } - - const dateStr = new Date(note.created_at).toLocaleString(); - - item.innerHTML = ` -No notes match.
'; - } - - filteredNotes.forEach(note => { - const item = document.createElement('div'); - item.className = 'sidebar-note-item'; - - let actionBtns = ''; - if (window.SpotNotes.activePadCanWrite) { - const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\\\'").replace(/"/g, '"'); - const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\\\'").replace(/"/g, '"').replace(/\\n/g, '\\\\n'); - actionBtns = ` - - - `; - } else if (window.SpotNotes.activePadIsPublic) { - actionBtns = ``; - } - - const dateStr = new Date(note.created_at).toLocaleString(); - - item.innerHTML = ` -Loading...
-Loading...
Error loading data.
'; - } - } else { - // Logged out: Only Public Notepads - sidebarBody.innerHTML = ` -Loading...
Loading...
'; - try { - const res = await fetch('/api/notepads?type=public'); - const pads = await res.json(); - renderNotepadList(pads, 'public-notepads-list'); - } catch (err) { - list.innerHTML = 'Error loading public pads.
'; - } - } - } else { - list.style.display = 'none'; - btn.innerHTML = 'Public Notepads ▸'; - } - }); -} - -function renderNotepadList(pads, containerId, isFavorite) { - const container = document.getElementById(containerId); - container.innerHTML = ''; - - if (pads.length === 0) { - container.innerHTML = 'No notepads found.
'; - return; - } - - pads.forEach(pad => { - const item = document.createElement('div'); - item.className = 'notepad-item'; - item.style.padding = '10px'; - item.style.borderBottom = '1px solid #eee'; - item.style.cursor = 'pointer'; - - let icon = pad.is_public ? '🌍' : '🔒'; - - let favButton = ''; - const editBtn = (currentUser && pad.owner_id === currentUser.id) ? - `` - : ''; - - const isOwner = currentUser && pad.owner_id === currentUser.id; - const manageShareBtn = isOwner ? - `` - : ''; - - if(isLoggedIn){ - const action = isFavorite ? 'unfavorite' : 'favorite'; - const label = isFavorite ? '♥' : '♡'; - favButton = ``; - } - - const deleteBtn = isOwner ? - `` - : ''; - - const shareToken = pad.share_token || ''; - const shareBtn = ``; - - item.innerHTML = ` -Error loading data.
'; - } -} -async function loadNotesForPad(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined) { - activePadId = padId; - - // If metadata is missing (e.g. from URL share), fetch it - if (padName === undefined) { - try { - const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`); - const pad = await res.json(); - padName = pad.name; isPublic = pad.is_public; ownerId = pad.owner_id; activePadCanWrite = pad.can_write; - } catch(e) { console.error(e); activePadCanWrite = false; } - } - - if (padName !== undefined) activePadName = padName; - if (isPublic !== undefined) activePadIsPublic = isPublic; - if (ownerId !== undefined) activePadOwnerId = ownerId; - - if (canWrite !== undefined) { - activePadCanWrite = canWrite; - } else if (padName !== undefined) { - // Fallback if not passed: assume true only if owner - activePadCanWrite = (currentUser && ownerId === currentUser.id); - } - - const currentName = activePadName; - const isOwner = currentUser && activePadOwnerId === currentUser.id; - - // Clear existing markers - currentMarkers.forEach(marker => map.removeLayer(marker)); - currentMarkers = []; - activeNotes = []; - - // Update Sidebar Header - const header = document.querySelector('.sidebar-header'); - if (header) { - const addNoteBtn = activePadCanWrite ? `` : ''; - const manageShareBtn = isOwner ? `` : ''; - const exportBtn = ``; - header.innerHTML = ` -Loading notes...
'; - - try { - const res = await fetch(`/api/notepads/${padId}/notes?token=${token || ''}`); - const notes = await res.json(); - activeNotes = notes; - - // Render Sidebar List - sidebarBody.innerHTML = ''; - if (notes.length === 0) { - sidebarBody.innerHTML = 'No notes yet.
'; - } - - notes.forEach(note => { - const item = document.createElement('div'); - item.className = 'sidebar-note-item'; - - let actionBtns = ''; - if (activePadCanWrite) { - const escapedTitle = escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); - const escapedContent = escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); - actionBtns = ` - - - `; - } else if (activePadIsPublic) { - actionBtns = ``; - } - - const dateStr = new Date(note.created_at).toLocaleString(); - - item.innerHTML = ` -Error loading notes.
'; - } -} - -async function loadSingleNote(noteId, token = null) { - // Reset state - activeNotes = []; - currentMarkers.forEach(marker => map.removeLayer(marker)); - currentMarkers = []; - activePadId = null; - - const sidebarBody = document.getElementById('sidebar-body'); - sidebarBody.innerHTML = 'Loading note...
'; - - try { - const res = await fetch(`/api/notes/${noteId}?token=${token || ''}`); - if (!res.ok) throw new Error("Note not found or private"); - const note = await res.json(); - - // Set active notes to just this one so renderMapMarkers works - activeNotes = [note]; - - // Update Header - const header = document.querySelector('.sidebar-header'); - if (header) { - header.innerHTML = ` -Failed to load note. It may be private or deleted.
'; - } -} - -function renderMapMarkers() { - // Clear existing - currentMarkers.forEach(marker => map.removeLayer(marker)); - currentMarkers = []; - - const zoom = map.getZoom(); - const showFull = zoom >= 14; // Zoom threshold - - activeNotes.forEach(note => { - let marker; - let actionBtns = ''; - - // Determine permissions: Use note-specific info if available (search results), else fall back to pad globals - const isOwner = currentUser && (note.owner_id ? note.owner_id === currentUser.id : activePadOwnerId === currentUser.id); - const isPublic = note.is_public !== undefined ? note.is_public : activePadIsPublic; - const canWrite = (note.notepad && note.notepad.can_write !== undefined) ? note.notepad.can_write : activePadCanWrite; - - if (canWrite) { - const escapedTitle = escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); - const escapedContent = escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); - actionBtns = `No results found.
'; - renderMapMarkers(); - return; - } else if (notes.length === 0 && location) { - sidebarBody.innerHTML = 'Location found on map.
'; - renderMapMarkers(); - map.setView([location.lat, location.lng], 13); - return; - } - - // Group notes by section and notepad - const sections = { - favorites: {}, // MapNo matching notes.
'; - } else { - padIds.forEach(padId => { - const entry = dataMap[padId]; - const pad = entry.pad; - - // Notepad Header, styled like the default view - const padItem = document.createElement('div'); - padItem.className = 'notepad-item'; - padItem.style.backgroundColor = '#e9ecef'; // Differentiate as a sub-header - padItem.style.cursor = 'default'; - - let icon = pad.is_public ? '🌍' : '🔒'; - let favButton = ''; - if(isLoggedIn){ - const isFavorite = pad.is_favorite; - const action = isFavorite ? 'unfavorite' : 'favorite'; - const label = isFavorite ? '♥' : '♡'; - favButton = ``; - } - - padItem.innerHTML = ` -Loading...
'; - modal.classList.remove('hidden'); - - try { - const res = await fetch(`/api/notepads/${padId}/collaborators`); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Failed to load collaborators'); - } - const collaborators = await res.json(); - - listDiv.innerHTML = ''; - if (collaborators.length === 0) { - listDiv.innerHTML = 'No collaborators yet.
'; - } else { - collaborators.forEach(collab => { - const item = document.createElement('div'); - item.className = 'collaborator-item'; - item.innerHTML = ` - ${escapeHtml(collab.email)} (${collab.permission}) - - `; - listDiv.appendChild(item); - }); - } - } catch (err) { - listDiv.innerHTML = `${err.message}
`; - } -} - -window.removeCollaborator = async function(padId, userId) { - if (!confirm("Are you sure you want to remove this collaborator?")) return; - - try { - const res = await fetch(`/api/notepads/${padId}/collaborators/${userId}`, { method: 'DELETE' }); - if (res.ok) { - const padName = document.getElementById('share-pad-name').textContent; - openShareSettingsModal(padId, padName); - } else { - const err = await res.json(); - alert(`Failed to remove collaborator: ${err.error}`); - } - } catch (err) { - console.error(err); - alert('An error occurred.'); - } -} - -async function loadAllMyNotes() { - activePadId = null; - activePadName = "All My Notes"; - activePadIsPublic = false; - activePadCanWrite = true; - if (currentUser) { - activePadOwnerId = currentUser.id; - } - - // Clear existing markers - currentMarkers.forEach(marker => map.removeLayer(marker)); - currentMarkers = []; - activeNotes = []; - - try { - const res = await fetch('/api/notes/mine'); - if (!res.ok) throw new Error("Failed to load"); - const notes = await res.json(); - activeNotes = notes; - - renderMapMarkers(); - - if (notes.length > 0) { - // Auto-center map bounds cleanly around all the user's notes - const bounds = L.latLngBounds(notes.map(n => [n.lat, n.lng])); - map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 }); - } - - } catch (err) { - console.error("Error loading all my notes:", err); - } -} - -window.shareNotepad = function(padId, token) { - let url = `${window.location.origin}/?pad=${padId}`; - if (token) url += `&token=${token}`; - navigator.clipboard.writeText(url).then(() => { - alert("Notepad link copied to clipboard!"); - }); -}; - -window.shareNote = function(noteId, token) { - let url = `${window.location.origin}/?note=${noteId}`; - if (token) url += `&token=${token}`; - navigator.clipboard.writeText(url).then(() => { - alert("Note link copied to clipboard!"); - }); -}; - -function stopTracking() { - isTracking = false; - updateTrackingUI(); -} - -function updateTrackingUI() { - const btn = document.getElementById('btn-center-user'); - if (btn) { - if (isTracking) { - btn.textContent = "Tracking Location..."; - btn.style.backgroundColor = "#28a745"; - } else { - btn.textContent = "Target My Location"; - btn.style.backgroundColor = ""; - } - } -} - -function escapeHtml(text) { - if (text === null || text === undefined) return ''; - return String(text) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function toggleSection(event) { - const section = event.target.closest('.list-section'); - const content = section.querySelector('.list-section-content'); - const isCollapsed = content.style.display === 'none'; - content.style.display = isCollapsed ? 'block' : 'none'; - event.target.innerHTML = isCollapsed ? event.target.innerHTML.replace('▸', '▾') : event.target.innerHTML.replace('▾', '▸'); -} - -window.deleteNote = async function(noteId) { - if (!confirm("Delete this note?")) return; - - try { - const res = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' }); - if (res.ok) { - // If we are in a pad view, reload the pad. - if (activePadId) { - loadNotesForPad(activePadId); - } else { - // Otherwise (e.g., search results), the safest refresh is to return to the main list. - alert("Note deleted. Returning to notepad list."); - loadNotepads(); - } - } else { - alert("Failed to delete note."); - } - } catch (err) { console.error(err); } -}; \ No newline at end of file diff --git a/static/assets/js/sidebar.js b/static/assets/js/sidebar.js index dfabeed..40900e0 100644 --- a/static/assets/js/sidebar.js +++ b/static/assets/js/sidebar.js @@ -66,50 +66,51 @@ window.SpotNotes.renderNotepadList = function(pads, containerId, isFavorite) { container.innerHTML = ''; if (pads.length === 0) { - container.innerHTML = 'No notepads found.
'; + container.innerHTML = 'No notepads found.
'; return; } pads.forEach(pad => { const item = document.createElement('div'); - item.className = 'notepad-item'; - item.style.padding = '10px'; - item.style.borderBottom = '1px solid #eee'; - item.style.cursor = 'pointer'; + item.className = 'notepad-card'; let icon = pad.is_public ? '🌍' : '🔒'; let favButton = ''; const editBtn = (window.SpotNotes.currentUser && pad.owner_id === window.SpotNotes.currentUser.id) ? - `` + `` : ''; const isOwner = window.SpotNotes.currentUser && pad.owner_id === window.SpotNotes.currentUser.id; const manageShareBtn = isOwner ? - `` + `` : ''; if(window.SpotNotes.isLoggedIn){ const action = isFavorite ? 'unfavorite' : 'favorite'; const label = isFavorite ? '♥' : '♡'; - favButton = ``; + favButton = ``; } const deleteBtn = isOwner ? - `` + `` : ''; const shareToken = pad.share_token || ''; - const shareBtn = ``; + const shareBtn = ``; item.innerHTML = ` -