PageSourceSearch

https://chordviewer.web.app/js/version_manager.js?v=3.20

js chordviewer.web.app collected 2026-10-03 12:20:02 UTC 135,725 bytes, 2,514 lines download raw bytes

1import { getDb, getFirebaseAuth } from './firebase_manager.js';
2import { collection, query, where, getDocs, doc, getDoc, orderBy } from "https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js";
3
4function checkAdminEmail(email) {
5    if (typeof window !== 'undefined' && typeof window.isAdminEmail === 'function') return window.isAdminEmail(email);
6    if (!email) return false;
7    const normalized = email.toLowerCase().trim().replace(/\./g, '');
8    const base = normalized.split('@')[0];
9    if (base === 'albertogaraycanas' || base === 'albertogaray' || base === 'garay_alberto' || base === 'garayalberto' || base === 'agaraycanas') return true;
10    return normalized === '[email protected]'.replace(/\./g, '') ||
11           normalized === '[email protected]'.replace(/\./g, '') ||
12           normalized === '[email protected]'.replace(/\./g, '') ||
13           normalized === '[email protected]'.replace(/\./g, '');
14}
15
16class VersionManager {
17    constructor() {
18        this.currentSongId = null;
19        this.currentOwnerId = null;
20        this.officialOwnerId = null;
21        this.officialNodeId = null;
22        this.currentNodeType = 'song'; // Default to song
23        this.currentNodeName = null;
24        this.parentName = null;
25        this.grandparentName = null;
26        this.versions = [];
27        this.userNamesCache = {};
28
29        // Sincronización automática si el usuario inicia o cierra sesión
30        if (typeof window !== 'undefined') {
31            import('./firebase_manager.js').then(({ listenToAuthChanges }) => {
32                listenToAuthChanges((user) => {
33                    this.refreshOwnershipUI();
34                    if (user) {
35                        this.checkUserNotifications(user);
36                    }
37                    if (typeof window.rerenderFormattedView === 'function') {
38                        window.rerenderFormattedView();
39                    }
40                });
41            }).catch(e => console.warn("Could not listen to auth in VersionManager:", e));
42
43            // Sincronización inicial diferida para actualizar la barra si ya hay contenido en el visor
44            setTimeout(() => {
45                this.syncWithCurrentViewerSelection();
46            }, 0);
47        }
48    }
49
50    syncWithCurrentViewerSelection() {
51        if (typeof window === 'undefined') return;
52        if (window._activeViewerSelection && typeof updateMetadataForNode === 'function') {
53            updateMetadataForNode(window._activeViewerSelection.type, window._activeViewerSelection.globalIndex);
54        } else {
55            this.refreshOwnershipUI();
56        }
57    }
58
59    async loadSongVersions(songId, ownerId, nodeType = 'song', nodeName = null, parentName = null, grandparentName = null, forceReload = false, preferredVersionId = null) {
60        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
61                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
62        if (isPersonal) {
63            this.currentNodeType = nodeType;
64            this.currentNodeName = nodeName;
65            this.parentName = parentName;
66            this.grandparentName = grandparentName;
67            document.getElementById('version-selector-container')?.classList.add('hidden');
68            document.getElementById('star-rating-display')?.classList.add('hidden');
69            return;
70        }
71
72        const isNewSong = this.currentSongId !== songId;
73        this.currentSongId = songId;
74        this.currentOwnerId = ownerId;
75        this.officialOwnerId = ownerId || this.officialOwnerId;
76        this.officialNodeId = songId || this.officialNodeId;
77        this.currentNodeType = nodeType;
78        this.currentNodeName = nodeName;
79        this.parentName = parentName;
80        this.grandparentName = grandparentName;
81
82        // Skip loading versions if already loaded for this song unless forceReload is requested
83        if (!forceReload && !isNewSong && this.versions.length > 0) {
84            if (preferredVersionId) {
85                this.populateVersionSelector(preferredVersionId);
86            }
87            return;
88        }
89
90        let db = getDb();
91        if (!db) {
92            try {
93                const { initFirebase } = await import('./firebase_manager.js');
94                await initFirebase();
95                db = getDb();
96            } catch (e) {
97                console.warn("Could not auto-init firebase in loadSongVersions:", e);
98            }
99        }
100        if (!db) {
101            console.warn("Firestore not initialized yet.");
102            return;
103        }
104
105        // Fetch versions from Firestore
106        try {
107            if (songId) {
108                const { doc, getDoc }
108 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
109                const songSnap = await getDoc(doc(db, 'songs', songId)).catch(() => null);
110                if (songSnap && songSnap.exists()) {
111                    this.currentMasterVersionId = songSnap.data()?.masterVersionId || null;
112                }
113            }
114            if (!this.currentMasterVersionId && typeof window.findNodeInLibrary === 'function') {
115                const libNode = window.findNodeInLibrary(nodeType, nodeName || this.currentNodeName, parentName || this.parentName, grandparentName || this.grandparentName);
116                if (libNode?.masterVersionId) {
117                    this.currentMasterVersionId = libNode.masterVersionId;
118                }
119            }
120
121            const q = query(
122                collection(db, 'versions'),
123                where('songId', '==', songId)
124            );
125
126            const snap = await getDocs(q);
127            this.versions = snap.docs.map(d => {
128                const data = d.data();
129                data.id = d.id;
130                return data;
131            });
132
133            // If no explicit versions in Firestore, build fallback master version from library node
134            if (this.versions.length === 0) {
135                let libNode = null;
136                if (typeof window.findNodeInLibrary === 'function') {
137                    libNode = window.findNodeInLibrary('song', nodeName || this.currentNodeName, parentName || this.parentName, grandparentName || this.grandparentName);
138                }
139                const defaultMasterId = this.currentMasterVersionId || (songId ? `${songId}_v1` : 'master_v1');
140                this.currentMasterVersionId = defaultMasterId;
141
142                let authorName = 'Alberto Garay Cañas';
143                if (libNode?.author) {
144                    authorName = libNode.author;
145                } else if (libNode?.ownerId && libNode.ownerId !== 'admin') {
146                    authorName = this.userNamesCache?.[libNode.ownerId] || `Usuario ${String(libNode.ownerId).substring(0, 6)}`;
147                }
148
149                const defaultMasterVersion = {
150                    id: defaultMasterId,
151                    songId: songId || this.currentSongId,
152                    content: libNode?.copyText || (document.getElementById('chordInput')?.value || ''),
153                    label: `Realizada por ${authorName}`,
154                    authorId: libNode?.ownerId || 'admin',
155                    ownerId: libNode?.ownerId || 'admin',
156                    status: 'approved',
157                    starsAvg: 3.0,
158                    ratingsCount: 1
159                };
160                this.versions = [defaultMasterVersion];
161            }
162
163            // Client-side sort: descending by createdAt
164            this.versions.sort((a, b) => {
165                const timeA = a.createdAt?.toMillis ? a.createdAt.toMillis() : (a.createdAt?.seconds * 1000 || 0);
166                const timeB = b.createdAt?.toMillis ? b.createdAt.toMillis() : (b.createdAt?.seconds * 1000 || 0);
167                return timeB - timeA;
168            });
169
170            this.populateVersionSelector(preferredVersionId);
171            const active = (preferredVersionId && this.versions.find(v => v.id === preferredVersionId)) ||
172                           (this.currentMasterVersionId && this.versions.find(v => v.id === this.currentMasterVersionId)) ||
173                           this.versions.find(v => v.status === 'approved') || 
174                           this.versions[0];
175            if (active) {
176                this.updateStarsUI(active.starsAvg ?? 3.0, active.ratingsCount ?? 1, active.id);
177            }
178        } catch (error) {
179            console.error("Error loading versions:", error);
180        }
181    }
182
183    resetUI() {
184        this.currentSongId = null;
185        this.currentOwnerId = null;
186        this.officialOwnerId = null;
187        this.officialNodeId = null;
188        this.currentNodeType = null;
189        this.currentNodeName = null;
190        this.parentName = null;
191        this.grandparentName = null;
192        this.versions = [];
193
194        if (typeof window.syncSongMetadataToolbar === 'function') {
195            window.syncSongMetadataToolbar();
196            return;
197        }
198
199        const delegateBtn = document.getElementById('delegate-branch-btn');
200        if (delegateBtn) delegateBtn.remove();
201
202        const requestOwnerBtn = document.getElementById('request-ownership-btn');
203        if (requestOwnerBtn) requestOwnerBtn.remove();
204
205        const nodeLabelEl = document.getElementById('current-node-label');
206        if (nodeLabelEl) nodeLabelEl.textContent = '';
207
208        const ownerNameEl = document.getElementById('current-owner-name');
209        if (ownerNameEl) ownerNameEl.textContent = '';
210    }
211
212    async updateOwnershipUI(ownerId, nodeType, nodeId, nodeName = null, parentName = null, grandparentName = null) {
213        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
214                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
215        if (!isPersonal) {
216            if (ownerId !== undefined && ownerId !== null) this.officialOwnerId = ownerId;
217            if (nodeId !== undefined && nodeId !== null) this.officialNodeId = nodeId;
218            this.currentOwnerId = ownerId;
219        } else {
220            this.currentOwnerId = null;
221        }
222        this.currentSongId = nodeId;
223        this.currentNodeType = nodeType;
224        this.currentNodeName = nodeName;
225        if (parentName) this.parentName = parentName;
226        if (grandparentName) this.grandparentName = grandparentName;
227
228        await this.refreshOwnershipUI();
229    }
230
231    async refreshOwnershipUI() {
232        if (typeof window.syncSongMetadataToolbar === 'function') {
233            window.syncSongMetadataToolbar();
234            return;
235        }
236
237        const toolbar = document.getElementById('song-metadata-toolbar');
238        const ownerNameEl = document.getElementById('current-owner-name');
239        const badgeEl = document.getElementById('ownership-badge');
240        const nodeLabelEl = document.getElementById('current-node-label');
241        const saveBtn = document.getElementById('save-node-btn');
242        const deleteBtn = document.getElementById('delete-node-btn');
243
244        if (!ownerNameEl || !toolbar) return;
245
246        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
247                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
248        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
249
250        // 1. Resolver el nodo actual con resolución en cascada
251        let nodeType = window._activeViewerSelection?.type || this.currentNodeType;
252        let nodeName = window._activeViewerSelection?.name || this.currentNodeName;
253        let parentName = window._activeViewerSelection?.parentName || this.parentName || null;
254        let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null;
255
256        // Si no está determinado, intentar desde la selección activa en el visor
257        if ((!nodeType || !nodeName) && window._activeViewerSelection && window._activeViewerSelection.name) {
258            nodeType = window._activeViewerSelection.type || nodeType;
259            nodeName = window._activeViewerSelection.name;
260            parentName = window._activeViewerSelection.parentName || parentName;
261            grandparentName = window._activeViewerSelection.grandparentName || grandparentName;
262        }
263
264        // Si aún no está determinado, intentar desde el texto del editor
265        const rawText = document.getElementById('chordInput')?.value || '';
266        if ((!nodeType || !nodeName) && rawText && rawText.trim().length > 0) {
267            const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im);
268            if (matchPlaylist) {
269                nodeType = 'playlist';
270                nodeName = matchPlaylist[1].trim();
271                parentName = null;
272                grandparentName = null;
273            } else {
274                const songMatches = rawText.match(/\(Song\)\s*(.*)/gi) || [];
275                const albumMatches = rawText.match(/\(Album\)\s*(.*)/gi) || [];
276                const matchBand = rawText.match(/\(Band\)\s*(.*)/i);
277                const matchAlbum = rawText.match(/\(Album\)\s*(.*)/i);
278                const matchSong = rawText.match(/\(Song\)\s*(.*)/i);
279
280                if (matchBand && (albumMatches.length > 1 || songMatches.length > 1)) {
281                    nodeType = 'band';
282                    nodeName = matchBand[1].trim();
283                } else if (matchAlbum && songMatches.length > 1) {
284                    nodeType = 'album';
285                    nodeName = matchAlbum[1].trim();
286                    if (matchBand) parentName = matchBand[1].trim();
287                } else if (matchSong) {
288                    nodeType = 'song';
289                    nodeName = matchSong[1].trim();
290                    if (matchAlbum) parentName = matchAlbum[1].trim();
291                    if (matchBand) grandparentName = matchBand[1].trim();
292                } else if (matchAlbum) {
293                    nodeType = 'album';
294                    nodeName = matchAlbum[1].trim();
295                    if (matchBand) parentName = matchBand[1].trim();
296                } else if (matchBand) {
297                    nodeType = 'band';
298                    nodeName = matchBand[1].trim();
299                }
300            }
301        }
302
303        // Si aún no está determinado, intentar desde window.parsedData
304        if ((!nodeType || !nodeName) && window.parsedData && window.parsedData.length > 0) {
305            const first = window.parsedData[0];
306            nodeType = (first.type || 'band').toLowerCase();
307            nodeName = first.name;
308        }
309
310        this.currentNodeType = nodeType;
311        this.currentNodeName = nodeName;
312        this.parentName = parentName;
313        this.grandparentName = grandparentName;
314
315        const hasViewerData = (window.parsedData && window.parsedData.length > 0);
316        const hasRawContent = (rawText && rawText.trim().length > 0);
317
318        // Si no hay datos ni en el visor ni en el editor ni nodo identificado, ocultar la barra
319        if (!hasViewerData && !hasRawContent && !nodeName) {
320            toolbar.classList.add('hidden');
321            return;
322        }
323
324        // Si hay datos en el visor o editor, SIEMPRE mostrar la barra
325        toolbar.classList.remove('hidden');
326
327        // Actualizar etiqueta del nodo (Playlist / Banda / Álbum / Canción)
328        const nodeTypeLower = (nodeType || 'song').toLowerCase();
329        const isSong = (nodeTypeLower === 'song');
330        const ownerLabelEl = document.getElementById('current-owner-label');
331
332        let delegateBtn = document.getElementById('delegate-branch-btn');
333        let reclaimBtn = document.getElementById('reclaim-branch-btn');
334        let requestOwnerBtn = document.getElementById('request-ownership-btn');
335
336        if (isPersonal) {
337            // ==================== MODO PARTICULAR ====================
338            if (nodeLabelEl) {
339                let labelType = isEn ? 'Song' : 'Canción';
340                if (nodeTypeLower === 'playlist') labelType = 'Playlist';
341                else if (nodeTypeLower === 'band') labelType = isEn ? 'Band' : 'Banda';
342                else if (nodeTypeLower === 'album') labelType = 'Album';
343
344                nodeLabelEl.textContent = nodeName ? `${labelType} - "${nodeName}" |` : `${labelType} |`;
345            }
346
347            if (ownerLabelEl) ownerLabelEl.classList.remove('hidden');
348            if (ownerNameEl) {
349                ownerNameEl.classList.remove('hidden');
350                ownerNameEl.textContent = isEn ? 'Your Personal Library' : 'Tu Biblioteca Particular';
351                ownerNameEl.className = 'text-indigo-600 dark:text-indigo-400 font-bold ml-0.5';
352            }
353
354            // Quitar botones de delegar, recuperar y solicitar de Firestore
355            if (delegateBtn) delegateBtn.remove();
356            if (reclaimBtn) reclaimBtn.remove();
357            if (requestOwnerBtn) requestOwnerBtn.remove();
358
359            // Ocultar selector de versiones y estrellas comunitarias
360            document.getElementById('version-selector-container')?.classList.add('hidden');
361            document.getElementById('star-rating-display')?.classList.add('hidden');
362
363            // Botón Guardar en Particular (Siempre habilitado)
364            if (saveBtn) {
365                saveBtn.disabled = false;
366                saveBtn.className = 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
367                saveBtn.title = isEn ? 'Save changes to your personal library' : 'Guardar cambios de este ítem en tu biblioteca particular';
368                saveBtn.innerHTML = `<span>💾</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`;
369            }
370
371            // Botón Borrar en Particular (Siempre habilitado)
372            if (deleteBtn) {
373                deleteBtn.disabled = false;
374                deleteBtn.className = 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
375                deleteBtn.title = isEn ? 'Delete this item from your personal library' : 'Eliminar este ítem de tu biblioteca particular';
376                deleteBtn.innerHTML = `<span>🗑️</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`;
377            }
378
379        } else {
380            // ==================== MODO OFICIAL ====================
381            let officialOwner = this.officialOwnerId;
382            let officialId = this.currentSongId;
383
384            if (nodeType === 'playlist') {
385                officialOwner = 'admin';
386                officialId = nodeName;
387                this.officialOwnerId = 'admin';
388                this.currentSongId = nodeName;
389            } else if (nodeType && nodeName && window.findNodeInLibrary) {
390                const libNode = window.findNodeInLibrary(nodeType, nodeName, parentName, grandparentName);
391                if (libNode) {
392                    officialOwner = libNode.ownerId || 'admin';
393                    officialId = libNode.id || null;
394                    this.officialOwnerId = officialOwner;
395                    this.currentSongId = officialId;
396                }
397            }
398            this.currentOwnerId = officialOwner;
399
400            const auth = getFirebaseAuth();
401            const currentUser = window.currentUser || auth?.currentUser;
402            const currentUid = currentUser?.uid;
403
404            const isAdminEmail = checkAdminEmail;
405            const isUserActuallyTheAdmin = Boolean(currentUid && (currentUid === 'admin' || isAdminEmail(currentUser?.email)));
406            const isOwner = Boolean(currentUid && officialOwner && currentUid === officialOwner);
407            const isAdminNode = officialOwner === 'admin';
408            const currentUserName = currentUser?.displayName || currentUser?.email?.split('@')[0] || (isEn ? 'You' : 'Tú');
409
410            if (isSong) {
411                // Para CANCIONES: La autoría pertenece a cada versión, no se muestra "Dueño: ..." en la barra superior
412                if (nodeLabelEl) {
413                    nodeLabelEl.textContent = nodeName ? `${isEn ? 'Song' : 'Canción'} - "${nodeName}"` : (isEn ? 'Song' : 'Canción');
414                }
415                if (ownerLabelEl) ownerLabelEl.classList.add('hidden');
416                if (ownerNameEl) ownerNameEl.classList.add('hidden');
417
418                if (delegateBtn) delegateBtn.remove();
419                if (reclaimBtn) reclaimBtn.remove();
420                if (requestOwnerBtn) requestOwnerBtn.remove();
421            } else {
422                // Para BANDAS, ÁLBUMES y PLAYLISTS: Mostrar claramente el gestor/dueño de la rama
423                let labelType = isEn ? 'Song' : 'Canción';
424                if (nodeTypeLower === 'playlist') labelType = 'Playlist';
425                else if (nodeTypeLower === 'band') labelType = isEn ? 'Band' : 'Banda';
426                else if (nodeTypeLower === 'album') labelType = 'Album';
427
428                if (nodeLabelEl) {
429                    nodeLabelEl.textContent = nodeName ? `${labelType} - "${nodeName}" |` : `${labelType} |`;
430                }
431                if (ownerLabelEl) ownerLabelEl.classList.remove('hidden');
432                if (ownerNameEl) ownerNameEl.classList.remove('hidden');
433
434                if (isOwner) {
435                    const suffix = isUserActuallyTheAdmin ? (isEn ? ' (You - Admin)' : ' (Tú - Admin)') : (isEn ? ' (You)' : ' (Tú)');
436                    ownerNameEl.textContent = `${currentUserName}${suffix}`;
437                    ownerNameEl.className = 'text-green-600 dark:text-green-400 font-bold ml-0.5';
438                } else if (isAdminNode) {
439                    if (isUserActuallyTheAdmin) {
440                        ownerNameEl.textContent = `${currentUserName}${isEn ? ' (You - Admin)' : ' (Tú - Admin)'}`;
441                        ownerNameEl.className = 'text-green-600 dark:text-green-400 font-bold ml-0.5';
442                    } else {
443                        ownerNameEl.textContent = 'Admin';
444                        ownerNameEl.className = 'text-blue-600 dark:text-blue-400 font-bold ml-0.5';
445                    }
446                } else if (officialOwner) {
447                    ownerNameEl.className = 'text-indigo-600 dark:text-indigo-400 font-bold ml-0.5';
448                    const ownerStr = String(officialOwner);
449                    ownerNameEl.textContent = `ID: ${ownerStr.substring(0, 8)}`;
450
451                    if (!this.userNamesCache) this.userNamesCache = {};
452                    if (this.userNamesCache[officialOwner]) {
453                        const name = this.userNamesCache[officialOwner];
454                        const isSelf = (currentUid === officialOwner);
455                        ownerNameEl.textContent = isSelf ? `${name}${isEn ? ' (You)' : ' (Tú)'}` : name;
456                    } else {
457                        import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js").then(({ doc, getDoc }) => {
458                            const db = getDb();
459                            if (!db) return;
460                            getDoc(doc(db, 'users', ownerStr)).then(uSnap => {
461                                if (uSnap.exists() && uSnap.data().displayName) {
462                                    const name = uSnap.data().displayName;
463                                    this.userNamesCache[officialOwner] = name;
464                                    if (this.currentOwnerId === officialOwner) {
465                                        const isSelf = (currentUid === officialOwner);
466                                        ownerNameEl.textContent = isSelf ? `${name}${isEn ? ' (You)' : ' (Tú)'}` : name;
467                                    }
468                                } else {
469                                    this.userNamesCache[officialOwner] = `ID: ${ownerStr.substring(0, 8)}`;
470                                }
471                            }).catch(e => console.error("Error fetching user info:", e));
472                        });
473                    }
474                } else {
475                    ownerNameEl.textContent = isEn ? 'Unregistered (New)' : 'Sin registrar (Nuevo)';
476                    ownerNameEl.className = 'text-gray-500 dark:text-gray-400 font-medium ml-0.5';
477                }
478            }
479
480            // Botones de Delegar / Recuperar / Solicitar en Oficial
481            const nodeTypeLower = (nodeType || 'song').toLowerCase();
482
483            let isParentBandOwner = false;
484            if (nodeTypeLower === 'album' && parentName && typeof window.findNodeInLibrary === 'function' && currentUid) {
485                const parentBand = window.findNodeInLibrary('band', parentName);
486                if (parentBand && (parentBand.ownerId === currentUid || (parentBand.ownerId === 'admin' && isUserActuallyTheAdmin))) {
487                    isParentBandOwner = true;
488                }
489            }
490
491            const canReclaim = (isUserActuallyTheAdmin || isParentBandOwner) && !isOwner && officialOwner;
492
493            if (isOwner && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId) {
494                if (requestOwnerBtn) requestOwnerBtn.remove();
495                if (reclaimBtn) reclaimBtn.remove();
496                if (!delegateBtn) {
497                    delegateBtn = document.createElement('button');
498                    delegateBtn.id = 'delegate-branch-btn';
499                    delegateBtn.className = 'ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 px-1.5 py-0.5 rounded hover:bg-indigo-200 dark:hover:bg-indigo-800 transition font-bold cursor-pointer';
500                    badgeEl.appendChild(delegateBtn);
501                }
502                delegateBtn.textContent = isEn ? 'Delegate' : 'Delegar';
503                delegateBtn.title = isEn ? 'Delegate this branch and its sub-items' : 'Delegar esta rama y todos sus elementos hijos';
504                delegateBtn.onclick = () => this.delegateBranch(nodeTypeLower, officialId);
505            } else if (canReclaim && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId) {
506                if (requestOwnerBtn) requestOwnerBtn.remove();
507                if (!reclaimBtn) {
508                    reclaimBtn = document.createElement('button');
509                    reclaimBtn.id = 'reclaim-branch-btn';
510                    reclaimBtn.className = 'ml-1.5 text-[9px] bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300 px-1.5 py-0.5 rounded hover:bg-rose-200 dark:hover:bg-rose-800 transition font-bold cursor-pointer';
511                    badgeEl.appendChild(reclaimBtn);
512                }
513                reclaimBtn.textContent = isEn ? 'Reclaim' : 'Recuperar';
514                reclaimBtn.title = isEn ? 'Reclaim ownership of this branch and all its child albums/songs' : 'Recuperar la propiedad de esta rama y todos sus álbumes/canciones';
515                reclaimBtn.onclick = () => this.reclaimBranch(nodeTypeLower, officialId, nodeName);
516
517                if (isUserActuallyTheAdmin) {
518                    if (!delegateBtn) {
519                        delegateBtn = document.createElement('button');
520                        delegateBtn.id = 'delegate-branch-btn';
521                        delegateBtn.className = 'ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 px-1.5 py-0.5 rounded hover:bg-indigo-200 dark:hover:bg-indigo-800 transition font-bold cursor-pointer';
522                        badgeEl.appendChild(delegateBtn);
523                    }
524                    delegateBtn.textContent = isEn ? 'Delegate' : 'Delegar';
525                    delegateBtn.title = isEn ? 'Delegate this branch and its sub-items' : 'Delegar esta rama y todos sus elementos hijos';
526                    delegateBtn.onclick = () => this.delegateBranch(nodeTypeLower, officialId);
527                } else if (delegateBtn) {
528                    delegateBtn.remove();
529                }
530            } else if (!isOwner && !canReclaim && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId && currentUser) {
531                if (delegateBtn) delegateBtn.remove();
532                if (reclaimBtn) reclaimBtn.remove();
533                if (!requestOwnerBtn) {
534                    requestOwnerBtn = document.createElement('button');
535                    requestOwnerBtn.id = 'request-ownership-btn';
536                    requestOwnerBtn.className = 'ml-1.5 text-[9px] bg-amber-100 text-amber-800 dark:bg-amber-900/60 dark:text-amber-300 px-1.5 py-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition font-bold cursor-pointer';
537                    badgeEl.appendChild(requestOwnerBtn);
538                }
539                requestOwnerBtn.textContent = isEn ? 'Request' : 'Solicitar';
540                requestOwnerBtn.title = isEn ? 'Request ownership of this branch' : 'Solicitar propiedad de esta rama';
541                requestOwnerBtn.onclick = () => this.handleRequestOwnership(nodeTypeLower, officialId, nodeName);
542            } else {
543                if (delegateBtn) delegateBtn.remove();
544                if (reclaimBtn) reclaimBtn.remove();
545                if (requestOwnerBtn) requestOwnerBtn.remove();
546            }
547
548            // Mostrar selector de versiones y estrellas en oficial si es canción registrada
549            if (nodeTypeLower === 'song' && officialId) {
550                document.getElementById('version-selector-container')?.classList.remove('hidden');
551                document.getElementById('star-rating-display')?.classList.remove('hidden');
552            } else {
553                document.getElementById('version-selector-container')?.classList.add('hidden');
554                document.getElementById('star-rating-display')?.classList.add('hidden');
555            }
556
557            // Permisos de Guardar y Borrar en Oficial
558            let isAuthorOfActiveVersion = false;
559            let userHasExistingVersion = false;
560            if (nodeTypeLower === 'song') {
561                const currentVerId = document.getElementById('version-select')?.value || this.currentMasterVersionId;
562                const currentVer = this.versions?.find(v => v.id === currentVerId) || this.versions?.[0];
563                const verAuthorUid = currentVer?.authorId || currentVer?.ownerId || currentVer?.userId;
564                if (currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin))) {
565                    isAuthorOfActiveVersion = true;
566                }
567                userHasExistingVersion = !!(currentUid && this.versions?.some(v => (v.authorId === currentUid || v.ownerId === currentUid || v.userId === currentUid)));
568            }
569
570            const hasWritePermission = (isUserActuallyTheAdmin || isOwner || isAuthorOfActiveVersion || userHasExistingVersion);
571            const canDeletePermission = (isUserActuallyTheAdmin || isOwner || isAuthorOfActiveVersion);
572
573            if (saveBtn) {
574                if (hasWritePermission) {
575                    saveBtn.disabled = false;
576                    saveBtn.className = 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
577                    saveBtn.title = isAuthorOfActiveVersion
578                        ? (isEn ? 'Save / update changes to your version' : 'Guardar y actualizar cambios en tu versión')
579                        : (isEn ? 'Save changes directly to the official catalog' : 'Guardar cambios directamente en el catálogo oficial');
580                    saveBtn.innerHTML = `<span>💾</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`;
581                } else if (currentUser) {
582                    saveBtn.disabled = false;
583                    saveBtn.className = 'px-2.5 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
584                    if (nodeTypeLower === 'song') {
585                        saveBtn.title = isEn ? 'Propose a new chord version (or switch to Personal Library to save privately)' : 'Proponer una nueva versión de acordes (o cambia a Biblioteca Particular para guardarla en privado)';
586                        saveBtn.innerHTML = `<span>💡</span> <span>${isEn ? 'Propose version' : 'Proponer versión'}</span>`;
587                    } else {
588                        saveBtn.title = isEn ? 'Submit branch proposal to owner/admin (or switch to Personal Library to save privately)' : 'Enviar propuesta al dueño/admin (o cambia a Biblioteca Particular para guardarla en privado)';
589                        saveBtn.innerHTML = `<span>💡</span> <span>${isEn ? 'Propose' : 'Proponer'}</span>`;
590                    }
591                } else {
592                    saveBtn.disabled = false;
593                    saveBtn.className = 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-70 cursor-pointer';
594                    saveBtn.title = isEn ? 'Log in with Google to propose in the official catalog (or switch to Personal Library)' : 'Inicia sesión con Google para el catálogo oficial (o cambia a Biblioteca Particular)';
595                    saveBtn.innerHTML = `<span>🔒</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`;
596                }
597            }
598
599            // Permisos de Borrar en Oficial
600            if (deleteBtn) {
601                if (canDeletePermission && officialId) {
602                    deleteBtn.disabled = false;
603                    deleteBtn.className = 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
604                    deleteBtn.title = isAuthorOfActiveVersion
605                        ? (isEn ? 'Delete your version permanently' : 'Eliminar tu versión permanentemente')
606                        : (isEn ? 'Delete this branch permanently from the official catalog' : 'Eliminar esta rama permanentemente del catálogo oficial');
607                    deleteBtn.innerHTML = `<span>🗑️</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`;
608                } else {
609                    deleteBtn.disabled = false;
610                    deleteBtn.className = 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-60 cursor-pointer';
611                    deleteBtn.title = isEn ? 'No permission to delete from the official catalog (only owner or admin)' : 'Sin permisos para borrar del catálogo oficial (solo dueño o admin)';
612                    deleteBtn.innerHTML = `<span>🗑️</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`;
613                }
614            }
615        }
616    }
617
618    async handleSaveNode() {
619        const rawText = document.getElementById('chordInput')?.value || '';
620        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
621
622        if (!rawText || !rawText.trim()) {
623            if (window.showToast) window.showToast(isEn ? "The editor is empty or has no valid content." : "El editor está vacío o no contiene formato válido.", "warning");
624            return;
625        }
626
627        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
628                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
629        const isPlaylistMode = (window.playlistManager && window.playlistManager.activeMode === 'playlist') || 
630                               (sessionStorage.getItem('chordlib_active_mode') === 'playlist');
631
632        let nodeType = (window._activeViewerSelection?.type || this.currentNodeType || '').toLowerCase();
633        let nodeName = window._activeViewerSelection?.name || this.currentNodeName;
634        let parentName = window._activeViewerSelection?.parentName || this.parentName || null;
635        let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null;
636        const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im);
637
638        if (!nodeName && matchPlaylist) {
639            nodeType = isPlaylistMode ? 'playlist' : 'song';
640            nodeName = matchPlaylist[1].trim();
641        } else if (!nodeName && window.parsedData && window.parsedData.length > 0) {
642            nodeType = (window.parsedData[0].type || (isPlaylistMode ? 'playlist' : 'band')).toLowerCase();
643            nodeName = window.parsedData[0].name;
644        }
645
646        this.currentNodeType = nodeType;
647        this.currentNodeName = nodeName;
648        this.parentName = parentName;
649        this.grandparentName = grandparentName;
650
651        // 0. MODO PLAYLIST: Grabar la playlist completa
652        if (isPlaylistMode) {
653            const playlistName = (matchPlaylist && matchPlaylist[1].trim()) || (nodeType === 'playlist' ? nodeName : 'Playlist');
654            if (isPersonal) {
655                if (window.playlistManager) {
656                    if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? `Saving playlist "${playlistName}"…` : `Guardando playlist "${playlistName}"…`);
657                    try {
658                        if (typeof window.playlistManager.saveSinglePlaylist === 'function') {
659                            await window.playlistManager.saveSinglePlaylist(playlistName, rawText, true);
660                        } else {
661                            await window.playlistManager.savePersonalPlaylists(rawText);
662                        }
663                        window.isDirty = false;
664                        window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText;
665                        if (window.showToast) window.showToast(isEn ? `Playlist "${playlistName}" saved to your personal library!` : `¡Playlist "${playlistName}" guardada en tu biblioteca particular!`, "success");
666                    } finally {
667                        if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
668                    }
669                }
670            } else {
671                const auth = getFirebaseAuth();
672                const currentUser = auth?.currentUser;
673                const isAdminEmail = checkAdminEmail;
674                const isSuperAdmin = (currentUser?.uid === 'admin' || isAdminEmail(currentUser?.email));
675                if (!isSuperAdmin) {
676                    if (window.showToast) window.showToast(isEn ? "Only administrators can save official playlists." : "Solo los administradores pueden guardar playlists en el catálogo oficial.", "warning");
677                    return;
678                }
679                if (window.playlistManager) {
680                    if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? `Saving playlist "${playlistName}"…` : `Guardando playlist "${playlistName}"…`);
681                    try {
682                        if (typeof window.playlistManager.saveSinglePlaylist === 'function') {
683                            await window.playlistManager.saveSinglePlaylist(playlistName, rawText, false);
684                        } else {
685                            await window.playlistManager.saveOfficialPlaylists(rawText, playlistName);
686                        }
687                        window.isDirty = false;
688                        window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText;
689                        if (window.showToast) window.showToast(isEn ? `Playlist "${playlistName}" saved to official catalog!` : `¡Playlist "${playlistName}" guardada en el catálogo oficial!`, "success");
690                    } finally {
691                        if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
692                    }
693                }
694            }
695            return;
696        }
697
698        // 1. MODO CANCIÓN: Guardar canción, banda o álbum
699        const isFromPlaylist = !!matchPlaylist || (grandparentName === 'Playlist') || (/^[^\(]+\([^\)]+\)$/.test(nodeName || '')) || (!/^\s*\(Band\)/im.test(rawText));
700
701        const extractBodyForActiveSong = () => {
702            let body = '';
703            if (window.parsedData && window.parsedData.length > 0) {
704                const selIdx = window._activeViewerSelection?.globalIndex;
705                let currentGlobalSongIndex = 0;
706                for (const band of window.parsedData) {
707                    const isPlaylist = band.type === 'Playlist' || !band.albums;
708                    const albumsList = isPlaylist
709                        ? [{ name: band.name, isPlaylistContainer: true, songs: band.songs || [] }]
710                        : (band.albums || []);
711                    for (const album of albumsList) {
712                        const songList = album.songs || [];
713                        for (let i = 0; i < songList.length; i++) {
714                            const s = songList[i];
715                            if ((selIdx !== undefined && selIdx !== null && currentGlobalSongIndex === selIdx) ||
716                                (nodeName && s.name && s.name.toLowerCase() === nodeName.toLowerCase())) {
717                                if (window.playlistManager && typeof window.playlistManager.extractSongBody === 'function') {
718                                    body = window.playlistManager.extractSongBody(s);
719                                }
720                                if (s.name && (!nodeName || nodeName === 'Playlist')) nodeName = s.name;
721                                break;
722                            }
723                            currentGlobalSongIndex++;
724                        }
725                        if (body) break;
726                    }
727                    if (body) break;
728                }
729            }
730            if (!body) body = rawText;
731            return body;
732        };
733
734        if (isPersonal) {
735            // ==================== MODO PARTICULAR ====================
736            // En biblioteca particular NO hay gestión de versiones: se guarda/actualiza directamente la canción
737            if (nodeType === 'song' || isFromPlaylist) {
738                const songBody = extractBodyForActiveSong();
739                const deduced = (typeof window.deduceSongTarget === 'function')
740                    ? window.deduceSongTarget(nodeName, songBody, true, grandparentName, parentName)
741                    : { songTitle: nodeName, bandName: grandparentName || 'Mis Canciones', albumName: parentName || 'Singles', body: songBody, isExisting: false };
742
743                const destName = isEn ? 'Personal Library' : 'Biblioteca Particular';
744                const actionLabel = deduced.isExisting
745                    ? (isEn ? 'update this song' : 'actualizar esta canción')
746                    : (isEn ? 'save this song' : 'guardar esta canción');
747
748                const msg = isEn
749                    ? `Do you want to ${actionLabel} in your ${destName}?\n\n• Song: "${deduced.songTitle}"\n• Artist / Band: "${deduced.bandName}"\n• Album: "${deduced.albumName}"`
750                    : `¿Deseas ${actionLabel} en tu ${destName}?\n\n• Canción: "${deduced.songTitle}"\n• Artista / Banda: "${deduced.bandName}"\n• Álbum: "${deduced.albumName}"`;
751
752                if (typeof window.openConfirmModal === 'function') {
753                    window.openConfirmModal(msg, async () => {
754                        if (window.personalLibraryManager) {
755                            if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Saving to personal library…" : "Guardando en b
755iblioteca particular…");
756                            try {
757                                const formattedSongText = `(Band) ${deduced.bandName}\n(Album) ${deduced.albumName}\n(Song) ${deduced.songTitle}\n${deduced.body}\n`;
758                                await window.personalLibraryManager.mergeSongIntoPersonal(formattedSongText);
759                                window.isDirty = false;
760                                window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText;
761                                if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') {
762                                    window.personalLibraryManager.renderActiveLibrary();
763                                }
764                                if (window.showToast) window.showToast(isEn ? `Song "${deduced.songTitle}" saved to your Personal Library!` : `¡Canción "${deduced.songTitle}" guardada en tu Biblioteca Particular!`, "success");
765                            } finally {
766                                if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
767                            }
768                        }
769                    }, isEn ? 'Confirm Save' : 'Confirmar Guardar Canción', isEn ? 'Save' : 'Grabar');
770                }
771                return;
772            }
773
774            if (window.personalLibraryManager) {
775                if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Saving to personal library…" : "Guardando en biblioteca particular…");
776                try {
777                    await window.personalLibraryManager.mergeSongIntoPersonal(rawText);
778                    window.isDirty = false;
779                    window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText;
780                    if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') {
781                        window.personalLibraryManager.renderActiveLibrary();
782                    }
783                    if (window.showToast) window.showToast(isEn ? "Changes saved to your Personal Library!" : "¡Cambios guardados en tu Biblioteca Particular!", "success");
784                } finally {
785                    if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
786                }
787            }
788            return;
789        }
790
791        // 2. MODO OFICIAL (FIRESTORE)
792        const auth = getFirebaseAuth();
793        const currentUser = auth?.currentUser;
794        if (!currentUser) {
795            if (window.showToast) window.showToast("Debes iniciar sesión con Google para guardar en el catálogo oficial.", "info");
796            return;
797        }
798
799        const isAdminEmail = checkAdminEmail;
800        const isSuperAdmin = (currentUser.uid === 'admin' || isAdminEmail(currentUser.email));
801        const isOwner = (currentUser.uid === this.currentOwnerId);
802
803        // Si es canción y proviene de playlist o título deducible
804        if (nodeType === 'song' && isFromPlaylist) {
805            const songBody = extractBodyForActiveSong();
806            const deduced = (typeof window.deduceSongTarget === 'function')
807                ? window.deduceSongTarget(nodeName, songBody, false, grandparentName, parentName)
808                : { songTitle: nodeName, bandName: grandparentName || 'Mis Canciones', albumName: parentName || 'Singles', body: songBody, isExisting: false };
809
810            const formattedSongText = `(Band) ${deduced.bandName}\n(Album) ${deduced.albumName}\n(Song) ${deduced.songTitle}\n${deduced.body}\n`;
811
812            if (isSuperAdmin || isOwner) {
813                const msg = isEn
814                    ? `Do you want to save changes for "${deduced.songTitle}" directly to the Official Catalog?\n\n• Song: "${deduced.songTitle}"\n• Band: "${deduced.bandName}"\n• Album: "${deduced.albumName}"`
815                    : `¿Deseas guardar los cambios de "${deduced.songTitle}" directamente en el Catálogo Oficial?\n\n• Canción: "${deduced.songTitle}"\n• Banda: "${deduced.bandName}"\n• Álbum: "${deduced.albumName}"`;
816
817                if (typeof window.openConfirmModal === 'function') {
818                    window.openConfirmModal(msg, () => {
819                        const chordInput = document.getElementById('chordInput');
820                        if (chordInput) chordInput.value = formattedSongText;
821                        if (typeof window.startOfficialCloudUpload === 'function') {
822                            window.startOfficialCloudUpload();
823                        }
824                    }, isEn ? 'Confirm Save' : 'Confirmar Guardar', isEn ? 'Save' : 'Grabar');
825                }
826                return;
827            } else {
828                const msg = isEn
829                    ? `Do you want to submit a version proposal for "${deduced.songTitle}" to the Official Catalog?\n\n• Song: "${deduced.songTitle}"\n• Band: "${deduced.bandName}"\n• Album: "${deduced.albumName}"`
830                    : `¿Deseas enviar una propuesta de versión para "${deduced.songTitle}" al Catálogo Oficial?\n\n• Canción: "${deduced.songTitle}"\n• Banda: "${deduced.bandName}"\n• Álbum: "${deduced.albumName}"`;
831
832                if (typeof window.openConfirmModal === 'function') {
833                    window.openConfirmModal(msg, async () => {
834                        await this.submitProposal(deduced.isExisting ? 'edit_song' : 'new_song', formattedSongText);
835                    }, isEn ? 'Submit Proposal' : 'Enviar Propuesta', isEn ? 'Submit' : 'Proponer');
836                }
837                return;
838            }
839        }
840
841        // 2.A: SuperAdmin o Dueño de la rama -> Sube directo con resumen de cambios
842        if (isSuperAdmin || isOwner) {
843            if (typeof window.startOfficialCloudUpload === 'function') {
844                window.startOfficialCloudUpload();
845            }
846            return;
847        }
848
849        // 2.B: BANDA y NO es SuperAdmin/Dueño -> Diálogo de propuesta de modificaciones
850        if (nodeType === 'band') {
851            const isExistingBand = !!this.currentSongId || !!this.officialNodeId || !!this.officialOwnerId || 
852                                   (typeof window.findNodeInLibrary === 'function' && !!window.findNodeInLibrary('band', nodeName));
853            const msg = isExistingBand
854                ? `¿Deseas enviar las modificaciones de la banda "${nodeName}" como propuesta para que el dueño/administrador las revise?`
855                : `¿Deseas proponer la creación de la nueva banda "${nodeName}" para que el Administrador la integre en el catálogo oficial?`;
856
857            if (typeof window.openConfirmModal === 'function') {
858                window.openConfirmModal(msg, async () => {
859                    await this.submitProposal(isExistingBand ? 'edit_band' : 'new_band', rawText);
860                });
861            }
862            return;
863        }
864
865        // 2.C: ÁLBUM en banda ajena -> Diálogo de propuesta al Dueño
866        if (nodeType === 'album') {
867            const isExistingAlbum = !!this.currentSongId || !!this.officialNodeId || 
868                                    (typeof window.findNodeInLibrary === 'function' && !!window.findNodeInLibrary('album', nodeName, parentName));
869            const msg = isExistingAlbum
870                ? `¿Deseas enviar las modificaciones del álbum "${nodeName}" como propuesta al dueño para su revisión?`
871                : `¿Deseas proponer este nuevo álbum "${nodeName}" como propuesta para que el dueño de la banda lo integre en su catálogo oficial?`;
872
873            if (typeof window.openConfirmModal === 'function') {
874                window.openConfirmModal(msg, async () => {
875                    await this.submitProposal(isExistingAlbum ? 'edit_album' : 'new_album', rawText);
876                });
877            }
878            return;
879        }
880
881        // 2.D: CANCIÓN en rama ajena -> Guardar como versión (Límite estricto de 5 versiones en total, máximo 1 por usuario)
882        if (nodeType === 'song') {
883            if (!this.currentSongId && typeof window.findNodeInLibrary === 'function') {
884                const libSong = window.findNodeInLibrary('song', nodeName, parentName, grandparentName);
885                if (libSong?.id) this.currentSongId = libSong.id;
886            }
887
888            if (!this.currentSongId) {
889                const msg = `¿Deseas proponer la nueva canción "${nodeName}" para que se integre en el catálogo oficial?`;
890                if (typeof window.openConfirmModal === 'function') {
891                    window.openConfirmModal(msg, async () => {
892                        await this.submitProposal('new_song', rawText);
893                    });
894                }
895                return;
896            }
897
898            const auth = getFirebaseAuth();
899            const currentUserId = auth?.currentUser?.uid;
900            const existingUserVersion = this.versions && this.versions.find(v => (v.ownerId === currentUserId || v.authorId === currentUserId));
901
902            // Si el usuario NO tiene aún una versión y ya se alcanzaron las 5 versiones totales, ofrecer propuesta de desborde
903            if (!existingUserVersion && this.versions && this.versions.length >= 5) {
904                const limitMsg = `Se ha alcanzado el límite máximo de 5 versiones para "${nodeName}". ¿Deseas enviarla como propuesta para que el dueño sustituya o archive una de las versiones existentes?`;
905                if (typeof window.openConfirmModal === 'function') {
906                    window.openConfirmModal(limitMsg, async () => {
907                        await this.submitProposal('overflow_song_version', rawText);
908                    });
909                } else if (window.showToast) {
910                    window.showToast(limitMsg, "warning");
911                }
912                return;
913            }
914
915            await this.submitSongVersion(rawText, existingUserVersion?.id);
916        }
917    }
918
919    async submitProposal(type, rawText) {
920        const db = getDb();
921        const auth = getFirebaseAuth();
922        if (!db || !auth?.currentUser) return;
923
924        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
925        if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Submitting proposal…" : "Enviando propuesta…");
926
927        try {
928            const { collection, addDoc, serverTimestamp }
928 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
929            await addDoc(collection(db, 'proposals'), {
930                type,
931                rawText,
932                targetNodeId: this.currentSongId || null,
933                targetOwnerId: this.currentOwnerId || null,
934                targetNodeType: this.currentNodeType || null,
935                submittedBy: auth.currentUser.uid,
936                submittedByName: auth.currentUser.displayName || auth.currentUser.email,
937                createdAt: serverTimestamp(),
938                status: 'pending'
939            });
940
941            if (window.showToast) {
942                window.showToast("Propuesta enviada correctamente para revisión.", "success");
943            }
944        } catch (err) {
945            console.error("Error submitting proposal:", err);
946            if (window.showToast) window.showToast("Error al enviar la propuesta.", "error");
947        } finally {
948            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
949        }
950    }
951
952    async submitSongVersion(rawText, existingVersionId = null) {
953        const db = getDb();
954        const auth = getFirebaseAuth();
955        if (!db || !auth?.currentUser || !this.currentSongId) return;
956
957        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
958        if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Submitting version…" : "Publicando versión…");
959
960        try {
961            const { collection, doc, setDoc, updateDoc, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
962            const authorName = auth.currentUser.displayName || auth.currentUser.email?.split('@')[0] || 'Comunidad';
963            const currentUserId = auth.currentUser.uid;
964
965            // Determinar si ya existe una versión previa de este usuario
966            let targetVersionId = existingVersionId;
967            if (!targetVersionId && this.versions) {
968                const myVersion = this.versions.find(v => (v.ownerId === currentUserId || v.authorId === currentUserId));
969                if (myVersion) targetVersionId = myVersion.id;
970            }
971
972            if (targetVersionId) {
973                // Sobrescribir la versión existente del mismo usuario
974                const existingVer = this.versions?.find(v => v.id === targetVersionId);
975                let updateLabel = existingVer?.label || `Realizada por ${authorName}`;
976                if (existingVer?.status === 'approved' && updateLabel.startsWith('Propuesta por')) {
977                    updateLabel = updateLabel.replace('Propuesta por', 'Realizada por');
978                }
979
980                const versionRef = doc(db, 'versions', targetVersionId);
981                await updateDoc(versionRef, {
982                    content: rawText,
983                    label: updateLabel,
984                    ownerId: currentUserId,
985                    authorId: currentUserId,
986                    updatedAt: serverTimestamp()
987                });
988
989                if (window.showToast) {
990                    window.showToast(isEn ? "Your chord version has been updated!" : "¡Tu versión de acordes ha sido actualizada!", "success");
991                }
992            } else {
993                // Crear versión única para este usuario
994                const isAdminEmail = checkAdminEmail;
995                const isUserActuallyTheAdmin = (currentUserId === 'admin' || isAdminEmail(auth.currentUser?.email));
996                const isBranchOwner = !!(this.currentOwnerId && currentUserId === this.currentOwnerId);
997                const canAutoApprove = (isUserActuallyTheAdmin || isBranchOwner);
998
999                const versionId = `${this.currentSongId}_contrib_${currentUserId}`;
1000                const versionRef = doc(db, 'versions', versionId);
1001
1002                await setDoc(versionRef, {
1003                    id: versionId,
1004                    songId: this.currentSongId,
1005                    content: rawText,
1006                    label: canAutoApprove ? `Realizada por ${authorName}` : `Propuesta por ${authorName}`,
1007                    ownerId: currentUserId,
1008                    authorId: currentUserId,
1009                    status: canAutoApprove ? 'approved' : 'pending',
1010                    starsAvg: 3.0,
1011                    ratingsCount: 1,
1012                    createdAt: serverTimestamp()
1013                }, { merge: true });
1014
1015                if (window.showToast) {
1016                    window.showToast(isEn ? (canAutoApprove ? "New chord version published successfully!" : "New chord version submitted as proposal!") : (canAutoApprove ? "¡Nueva versión de acordes publicada con éxito!" : "¡Nueva versión de acordes enviada como propuesta!"), "success");
1017                }
1018            }
1019
1020            await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true);
1021        } catch (err) {
1022            console.error("Error saving version:", err);
1023            if (window.showToast) window.showToast("Error al guardar la versión.", "error");
1024        } finally {
1025            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
1026        }
1027    }
1028
1029    async handleDeleteNode() {
1030        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
1031                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
1032        const isPlaylistMode = (window.playlistManager && window.playlistManager.activeMode === 'playlist') || 
1033                               (sessionStorage.getItem('chordlib_active_mode') === 'playlist');
1034        let nodeType = (window._activeViewerSelection?.type || this.currentNodeType || 'song').toLowerCase();
1035        let nodeName = window._activeViewerSelection?.name || this.currentNodeName;
1036        let parentName = window._activeViewerSelection?.parentName || this.parentName || null;
1037        let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null;
1038
1039        const rawText = document.getElementById('chordInput')?.value || '';
1040        const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im);
1041
1042        if (!nodeName && matchPlaylist) {
1043            nodeType = isPlaylistMode ? 'playlist' : 'song';
1044            nodeName = matchPlaylist[1].trim();
1045        } else if (!nodeName && window.parsedData && window.parsedData.length > 0) {
1046            nodeType = (window.parsedData[0].type || (isPlaylistMode ? 'playlist' : 'band')).toLowerCase();
1047            nodeName = window.parsedData[0].name;
1048        }
1049
1050        this.currentNodeType = nodeType;
1051        this.currentNodeName = nodeName;
1052        this.parentName = parentName;
1053        this.grandparentName = grandparentName;
1054
1055        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1056
1057        // 0. CONTEXTO PLAYLIST: distinguir entre borrar una canción de la playlist vs borrar la playlist entera
1058        if (isPlaylistMode) {
1059            const currentPlaylistName = (matchPlaylist && matchPlaylist[1].trim()) || (nodeType === 'playlist' ? nodeName : 'Playlist');
1060
1061            // Si hay una canción seleccionada dentro de la playlist, eliminar la canción — NO la playlist entera
1062            if (nodeType === 'song' && nodeName) {
1063                const songIndex = window._activeViewerSelection?.globalIndex ?? null;
1064                if (window.playlistManager && typeof window.playlistManager.confirmDeleteSongFromPlaylist === 'function') {
1065                    window.playlistManager.confirmDeleteSongFromPlaylist(currentPlaylistName, nodeName, songIndex, isPersonal);
1066                }
1067                return;
1068            }
1069
1070            // Nada concreto seleccionado o el nodo playlist en sí → borrar la playlist entera
1071            const playlistName = (nodeType === 'playlist' ? nodeName : null) || currentPlaylistName;
1072            if (!playlistName) {
1073                if (window.showToast) window.showToast(isEn ? "No playlist selected to delete." : "No se ha seleccionado ninguna playlist para borrar.", "warning");
1074                return;
1075            }
1076
1077            if (window.playlistManager && typeof window.playlistManager.confirmDeletePlaylist === 'function') {
1078                window.playlistManager.confirmDeletePlaylist(playlistName);
1079            }
1080            return;
1081        }
1082
1083        // 1. MODO PARTICULAR
1084        if (isPersonal) {
1085            if (!nodeName) {
1086                if (window.showToast) window.showToast(isEn ? "Select an item to delete." : "Selecciona una canción o banda para borrar.", "warning");
1087                return;
1088            }
1089
1090            const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (nodeType === 'album' ? 'álbum' : (isEn ? 'song' : 'canción'));
1091            const msg = isEn 
1092                ? `⚠️ Are you sure you want to delete ${typeLabel} "${nodeName}" from your personal library?`
1093                : `⚠️ ¿Seguro que deseas borrar este ${typeLabel} "${nodeName}" de tu biblioteca particular?`;
1094
1095            const onConfirmDeletePersonal = async () => {
1096                let bandName = null, albumName = null, songName = null;
1097
1098                if (nodeType === 'band') {
1099                    bandName = nodeName;
1100                } else if (nodeType === 'album') {
1101                    bandName = parentName || (window.parsedData && window.parsedData[0]?.name) || null;
1102                    albumName = nodeName;
1103                } else {
1104                    songName = nodeName;
1105                    albumName = parentName || null;
1106                    grandparentName = grandparentName || (window.parsedData && window.parsedData[0]?.name) || null;
1107                    bandName = grandparentName;
1108                }
1109
1110                if (window.personalLibraryManager) {
1111                    if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Deleting item…" : "Eliminando elemento…");
1112                    try {
1113                        await window.personalLibraryManager.deleteNodeFromPersonal({ bandName, albumName, songName });
1114                        if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') {
1115                            window.personalLibraryManager.renderActiveLibrary();
1116                        }
1117                        if (window.showToast) window.showToast(isEn ? "Item deleted from personal library." : "Elemento eliminado de la biblioteca particular.", "info");
1118
1119                        // Si el elemento borrado era la banda completa o la única canción en el visor, limpiar el visor
1120                        const chordInput = document.getElementById('chordInput');
1121                        if (chordInput && (nodeType === 'band' || (window.parsedData && window.parsedData.length <= 1))) {
1122                            chordInput.value = '';
1123                            window._lastCleanEditorText = '';
1124                            window.isDirty = false;
1125                            if (typeof window.processChords === 'function') window.processChords();
1126                        }
1127                    } finally {
1128                        if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
1129                    }
1130                }
1131            };
1132
1133            if (typeof window.openConfirmModal === 'function') {
1134                window.openConfirmModal(msg, onConfirmDeletePersonal);
1135            }
1136            return;
1137        }
1138
1139        // 2. MODO OFICIAL (FIRESTORE)
1140        const auth = getFirebaseAuth();
1141        const currentUser = auth?.currentUser;
1142        if (!currentUser) {
1143            if (window.showToast) window.showToast("Debes iniciar sesión para borrar elementos del catálogo oficial.", "warning");
1144            return;
1145        }
1146
1147        const isAdminEmail = checkAdminEmail;
1148        const isSuperAdmin = (currentUser.uid === 'admin' || isAdminEmail(currentUser.email));
1149        const isOwner = (currentUser.uid === this.currentOwnerId);
1150
1151        // Si es una canción, comprobar si el usuario es el autor de la versión activa
1152        if (nodeType === 'song') {
1153            const activeVer = this.versions?.find(v => v.id === (document.getElementById('version-select')?.value || this.currentMasterVersionId)) || this.versions?.[0];
1154            const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId;
1155            const isAuthorOfActiveVersion = !!(currentUser && (verAuthorUid === currentUser.uid || (verAuthorUid === 'admin' && isSuperAdmin)));
1156
1157            if (!isSuperAdmin && !isOwner && !isAuthorOfActiveVersion) {
1158                if (window.showToast) window.showToast("No tienes permisos para borrar esta versión del catálogo oficial.", "error");
1159                return;
1160            }
1161
1162            if (activeVer) {
1163                const msg = isEn ? `⚠️ Are you sure you want to delete this version ("${activeVer.label || 'Versión'}")?` : `⚠️ ¿Seguro que deseas eliminar esta versión ("${activeVer.label || 'Versión'}")?`;
1164                if (typeof window.openConfirmModal === 'function') {
1165                    window.openConfirmModal(msg, () => this.rejectProposal(activeVer.id));
1166                }
1167                return;
1168            }
1169        }
1170
1171        if (!isSuperAdmin && !isOwner) {
1172            if (window.showToast) window.showToast("No tienes permisos para borrar esta rama del catálogo oficial.", "error");
1173            return;
1174        }
1175
1176        const msg = `⚠️ ATENCIÓN: Esta acción eliminará permanentemente la rama ${nodeType.toUpperCase()} del catálogo oficial en la nube. ¿Estás seguro?`;
1177        const onConfirmDeleteOfficial = async () => {
1178            const db = getDb();
1179            if (!db || !this.currentSongId) return;
1180            if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Deleting from cloud…" : "Eliminando del catálogo oficial…");
1181            try {
1182                const { deleteDoc, doc }
1182 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1183                const collName = nodeType === 'band' ? 'bands' : (nodeType === 'album' ? 'albums' : 'songs');
1184                await deleteDoc(doc(db, collName, this.currentSongId));
1185
1186                if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0);
1187                if (typeof window.triggerSync === 'function') await window.triggerSync(true);
1188
1189                if (window.showToast) window.showToast("Rama eliminada del catálogo oficial.", "success");
1190            } catch (e) {
1191                console.error("Error deleting branch from Firestore:", e);
1192                if (window.showToast) window.showToast("Error al eliminar de Firestore.", "error");
1193            } finally {
1194                if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
1195            }
1196        };
1197
1198        if (typeof window.openConfirmModal === 'function') {
1199            window.openConfirmModal(msg, onConfirmDeleteOfficial);
1200        }
1201    }
1202
1203    async handleRequestOwnership(nodeType, nodeId, nodeName, targetOwnerId = null) {
1204        const auth = getFirebaseAuth();
1205        if (!auth?.currentUser) {
1206            const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1207            if (window.showToast) window.showToast(isEn ? "You must sign in to request branch ownership." : "Debes iniciar sesión para solicitar la propiedad.", "info");
1208            return;
1209        }
1210
1211        const effectiveNodeId = nodeId || (nodeName ? nodeName.toLowerCase().replace(/\s+/g, '_') : this.currentSongId);
1212        let effectiveOwnerId = targetOwnerId || this.currentOwnerId || this.officialOwnerId;
1213        if (!effectiveOwnerId && typeof window.findNodeInLibrary === 'function') {
1214            const libNode = window.findNodeInLibrary(nodeType, nodeName);
1215            if (libNode && libNode.ownerId) effectiveOwnerId = libNode.ownerId;
1216        }
1217        effectiveOwnerId = effectiveOwnerId || 'admin';
1218
1219        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1220        const typeLabel = (nodeType === 'band') ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum');
1221        const msg = isEn
1222            ? `Do you want to send a request to the current owner to transfer ownership and management of ${typeLabel} "${nodeName || ''}" to you?`
1223            : `¿Deseas enviar una solicitud al dueño actual para que te transfiera la propiedad de la ${typeLabel} "${nodeName || ''}"?`;
1224
1225        if (typeof window.openConfirmModal === 'function') {
1226            window.openConfirmModal(msg, async () => {
1227                const db = getDb();
1228                if (!db) return;
1229                try {
1230                    const { collection, addDoc, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1231                    await addDoc(collection(db, 'ownership_requests'), {
1232                        nodeId: effectiveNodeId,
1233                        nodeType,
1234                        nodeName,
1235                        targetOwnerId: effectiveOwnerId,
1236                        requesterUid: auth.currentUser.uid,
1237                        requesterName: auth.currentUser.displayName || auth.currentUser.email,
1238                        status: 'pending',
1239                        createdAt: serverTimestamp()
1240                    });
1241
1242                    if (window.showToast) window.showToast(isEn ? "Ownership request sent to owner." : "Solicitud de propiedad enviada al dueño.", "success");
1243                } catch (e) {
1244                    console.error("Error requesting ownership:", e);
1245                    if (window.showToast) window.showToast(isEn ? "Error requesting ownership." : "Error al solicitar propiedad.", "error");
1246                }
1247            });
1248        }
1249    }
1250
1251    async updateBranchOwnershipRecursively(nodeType, nodeId, newOwnerId, isReclaim = false, reclaimerName = null, branchDisplayName = null) {
1252        const db = getDb();
1253        if (!db) return;
1254
1255        const { doc, collection, query, where, getDocs, getDoc, writeBatch }
1255 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1256
1257        let batch = writeBatch(db);
1258        let count = 0;
1259        const evictedUids = new Set();
1260        const collectionName = nodeType === 'band' ? 'bands' : (nodeType === 'album' ? 'albums' : 'songs');
1261
1262        if (isReclaim) {
1263            try {
1264                const rootDocSnap = await getDoc(doc(db, collectionName, nodeId));
1265                if (rootDocSnap.exists()) {
1266                    const prevOwner = rootDocSnap.data()?.ownerId;
1267                    if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') {
1268                        evictedUids.add(prevOwner);
1269                    }
1270                }
1271            } catch (e) {
1272                console.warn("Could not check root doc owner before reclaim:", e);
1273            }
1274        }
1275
1276        const commitBatchIfNeeded = async () => {
1277            if (count >= 400) {
1278                await batch.commit();
1279                batch = writeBatch(db);
1280                count = 0;
1281            }
1282        };
1283
1284        if (nodeType === 'band') {
1285            // 1. Update band doc
1286            batch.update(doc(db, 'bands', nodeId), { ownerId: newOwnerId });
1287            count++;
1288            await commitBatchIfNeeded();
1289
1290            // 2. Query and update all child albums in this band
1291            try {
1292                const albumsQuery = query(collection(db, 'albums'), where('bandId', '==', nodeId));
1293                const albumsSnap = await getDocs(albumsQuery);
1294                for (const aDoc of albumsSnap.docs) {
1295                    if (isReclaim) {
1296                        const prevOwner = aDoc.data()?.ownerId;
1297                        if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') {
1298                            evictedUids.add(prevOwner);
1299                        }
1300                    }
1301                    batch.update(aDoc.ref, { ownerId: newOwnerId });
1302                    count++;
1303                    await commitBatchIfNeeded();
1304                }
1305            } catch (e) {
1306                console.warn("Error querying albums for recursive ownership update:", e);
1307            }
1308
1309            // 3. Query and update all child songs in this band
1310            try {
1311                const songsQuery = query(collection(db, 'songs'), where('bandId', '==', nodeId));
1312                const songsSnap = await getDocs(songsQuery);
1313                for (const sDoc of songsSnap.docs) {
1314                    if (isReclaim) {
1315                        const prevOwner = sDoc.data()?.ownerId;
1316                        if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') {
1317                            evictedUids.add(prevOwner);
1318                        }
1319                    }
1320                    batch.update(sDoc.ref, { ownerId: newOwnerId });
1321                    count++;
1322                    await commitBatchIfNeeded();
1323                }
1324            } catch (e) {
1325                console.warn("Error querying songs for recursive ownership update:", e);
1326            }
1327        } else if (nodeType === 'album') {
1328            // 1. Update album doc
1329            batch.update(doc(db, 'albums', nodeId), { ownerId: newOwnerId });
1330            count++;
1331            await commitBatchIfNeeded();
1332
1333            // 2. Query and update all child songs in this album
1334            try {
1335                const songsQuery = query(collection(db, 'songs'), where('albumId', '==', nodeId));
1336                const songsSnap = await getDocs(songsQuery);
1337                for (const sDoc of songsSnap.docs) {
1338                    if (isReclaim) {
1339                        const prevOwner = sDoc.data()?.ownerId;
1340                        if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') {
1341                            evictedUids.add(prevOwner);
1342                        }
1343                    }
1344                    batch.update(sDoc.ref, { ownerId: newOwnerId });
1345                    count++;
1346                    await commitBatchIfNeeded();
1347                }
1348            } catch (e) {
1349                console.warn("Error querying songs for album recursive ownership update:", e);
1350            }
1351        } else if (nodeType === 'song') {
1352            // Update single song doc
1353            batch.update(doc(db, 'songs', nodeId), { ownerId: newOwnerId });
1354            count++;
1355            await commitBatchIfNeeded();
1356        }
1357
1358        // 4. Bump sync_info metadata
1359        batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true });
1360        count++;
1361
1362        if (count > 0) {
1363            await batch.commit();
1364        }
1365
1366        // 5. Send eviction notifications to any users who lost ownership
1367        if (isReclaim && evictedUids.size > 0) {
1368            try {
1369                const { addDoc, serverTimestamp }
1369 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1370                const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1371                const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum');
1372                const authorName = reclaimerName || 'El Administrador';
1373                const branchName = branchDisplayName || nodeId;
1374
1375                for (const evictedUid of evictedUids) {
1376                    await addDoc(collection(db, 'user_notifications'), {
1377                        userId: evictedUid,
1378                        type: 'branch_reclaimed',
1379                        nodeType,
1380                        nodeId,
1381                        nodeName: branchName,
1382                        reclaimedBy: newOwnerId,
1383                        reclaimedByName: authorName,
1384                        title: isEn ? `Branch reclaimed: "${branchName}"` : `Propiedad de rama recuperada: "${branchName}"`,
1385                        message: isEn
1386                            ? `The ownership and management of the ${typeLabel} "${branchName}" (and all its sub-items) has been reclaimed by ${authorName}.\n\nYour direct editing access for this branch has ended.`
1387                            : `La propiedad y gestión de la ${typeLabel} "${branchName}" (y todos sus álbumes y canciones) ha sido recuperada por ${authorName}.\n\nTus permisos de gestión directa sobre esta rama han finalizado.`,
1388                        read: false,
1389                        createdAt: serverTimestamp()
1390                    });
1391                }
1392            } catch (e) {
1393                console.warn("Could not save eviction notifications:", e);
1394            }
1395        }
1396    }
1397
1398    async delegateBranch(nodeType, nodeId) {
1399        let effectiveNodeId = nodeId || this.currentSongId;
1400        if (!effectiveNodeId && typeof window.findNodeInLibrary === 'function') {
1401            const libNode = window.findNodeInLibrary(nodeType, this.currentNodeName, this.parentName, this.grandparentName);
1402            if (libNode?.id) effectiveNodeId = libNode.id;
1403        }
1404        if (!effectiveNodeId && this.currentNodeName) {
1405            effectiveNodeId = this.currentNodeName.toLowerCase().replace(/\s+/g, '_');
1406        }
1407        const nodeName = this.currentNodeName || effectiveNodeId;
1408
1409        const onConfirmDelegation = async (newOwnerId, newOwnerDisplayName) => {
1410            if (!newOwnerId || newOwnerId.trim() === "") return;
1411
1412            const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1413            if (typeof window.showLoadingModal === 'function') {
1414                window.showLoadingModal(isEn ? "Delegating branch ownership…" : "Delegando propiedad de la rama y contenidos…");
1415            }
1416
1417            try {
1418                await this.updateBranchOwnershipRecursively(nodeType, effectiveNodeId, newOwnerId.trim(), false, null, nodeName);
1419
1420                // Send notification to the newly assigned owner
1421                try {
1422                    const db = getDb();
1423                    const auth = getFirebaseAuth();
1424                    if (db) {
1425                        const { addDoc, collection, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1426                        const delegatorName = auth?.currentUser?.displayName || auth?.currentUser?.email || 'El Administrador';
1427                        const delegatorUid = auth?.currentUser?.uid || 'admin';
1428                        const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum');
1429
1430                        await addDoc(collection(db, 'user_notifications'), {
1431                            userId: newOwnerId.trim(),
1432                            type: 'branch_delegated',
1433                            nodeType,
1434                            nodeId: effectiveNodeId,
1435                            nodeName: nodeName,
1436                            delegatedBy: delegatorUid,
1437                            delegatedByName: delegatorName,
1438                            title: isEn ? `Branch delegated: "${nodeName}"` : `Nueva rama delegada: "${nodeName}"`,
1439                            message: isEn
1440                                ? `${delegatorName} has delegated the management of the ${typeLabel} "${nodeName}" (and all its child albums/songs) to you.\n\n⚠️ Important Notice: This delegation is revocable at any time by the administrator or original owner.`
1441                                : `${delegatorName} te ha delegado la gestión de la ${typeLabel} "${nodeName}" (y todos sus álbumes y canciones hijos).\n\n⚠️ Aviso importante: Esta cesión es revocable en cualquier momento por el administrador o dueño original.`,
1442                            read: false,
1443                            createdAt: serverTimestamp()
1444                        });
1445                    }
1446                } catch (ne) {
1447                    console.warn("Could not save delegation notification:", ne);
1448                }
1449
1450                // Force local sync to update the tree immediately
1451                if (window.syncManager) {
1452                    await window.syncManager.setMetadata('lastUpdated', 0);
1453                }
1454
1455                const displayName = newOwnerDisplayName || newOwnerId.substring(0, 8);
1456                const typeLabel = nodeType === 'band' 
1457                    ? (isEn ? 'band and all its albums/songs' : 'banda y todos sus álbumes y canciones') 
1458                    : (isEn ? 'album and all its songs' : 'álbum y todas sus canciones');
1459                const successMsg = isEn
1460                    ? `Ownership of ${typeLabel} delegated to ${displayName} successfully.`
1461                    : `Propiedad de la ${typeLabel} delegada a ${displayName} con éxito.`;
1462
1463                if (window.showToast) window.showToast(successMsg, 'success');
1464
1465                // Trigger background sync to refresh UI
1466                if (window.triggerSync) {
1467                    await window.triggerSync(true);
1468                } else {
1469                    setTimeout(() => location.reload(), 1200);
1470                }
1471            } catch (error) {
1472                console.error("Error delegating branch:", error);
1473                const errMsg = isEn
1474                    ? "Error delegating. Check your permissions."
1475                    : "Error al delegar. Asegúrate de tener permisos.";
1476                if (window.showToast) window.showToast(errMsg, 'error');
1477            } finally {
1478                if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
1479            }
1480        };
1481
1482        if (typeof window.openDelegateModal === 'function') {
1483            window.openDelegateModal(nodeType, effectiveNodeId, nodeName, onConfirmDelegation);
1484        } else {
1485            const title = typeof getLanguage === 'function' && getLanguage() === 'en' ? "Delegate Ownership" : "Delegar Propiedad";
1486            const placeholder = typeof getLanguage === 'function' && getLanguage() === 'en' ? "Enter new owner UID" : "Introduce el UID del nuevo dueño";
1487            window.openPromptModal(title, placeholder, onConfirmDelegation);
1488        }
1489    }
1490
1491    async reclaimBranch(nodeType, nodeId, nodeName) {
1492        const auth = getFirebaseAuth();
1493        if (!auth?.currentUser) {
1494            if (window.showToast) window.showToast("Debes iniciar sesión para recuperar la rama.", "info");
1495            return;
1496        }
1497
1498        let effectiveNodeId = nodeId || this.currentSongId;
1499        if (!effectiveNodeId && typeof window.findNodeInLibrary === 'function') {
1500            const libNode = window.findNodeInLibrary(nodeType, this.currentNodeName, this.parentName, this.grandparentName);
1501            if (libNode?.id) effectiveNodeId = libNode.id;
1502        }
1503        if (!effectiveNodeId && this.currentNodeName) {
1504            effectiveNodeId = this.currentNodeName.toLowerCase().replace(/\s+/g, '_');
1505        }
1506
1507        const typeLabel = nodeType === 'band' ? 'Banda' : 'Álbum';
1508        const msg = `¿Deseas recuperar la propiedad de la ${typeLabel} "${nodeName || ''}" y devolver el control recursivo de todos sus álbumes y canciones a tu cuenta?`;
1509
1510        if (typeof window.openConfirmModal === 'function') {
1511            window.openConfirmModal(msg, async () => {
1512                const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1513                if (typeof window.showLoadingModal === 'function') {
1514                    window.showLoadingModal(isEn ? "Reclaiming branch ownership…" : "Recuperando propiedad de la rama y contenidos…");
1515                }
1516
1517                try {
1518                    const reclaimerName = auth.currentUser.displayName || auth.currentUser.email || 'El Administrador';
1519                    // Reclaim ownership to the current user's UID (Admin or Band owner) and notify evicted users
1520                    await this.updateBranchOwnershipRecursively(nodeType, effectiveNodeId, auth.currentUser.uid, true, reclaimerName, nodeName);
1521
1522                    // Force local sync to update the tree immediately
1523                    if (window.syncManager) {
1524                        await window.syncManager.setMetadata('lastUpdated', 0);
1525                    }
1526
1527                    const successMsg = isEn
1528                        ? `Branch "${nodeName}" and all child items reclaimed successfully.`
1529                        : `Rama "${nodeName}" y todos sus elementos hijos recuperados con éxito.`;
1530
1531                    if (window.showToast) window.showToast(successMsg, 'success');
1532
1533                    if (window.triggerSync) {
1534                        await window.triggerSync(true);
1535                    } else {
1536                        setTimeout(() => location.reload(), 1200);
1537                    }
1538                } catch (error) {
1539                    console.error("Error reclaiming branch:", error);
1540                    const errMsg = isEn
1541                        ? "Error reclaiming branch. Check your permissions."
1542                        : "Error al recuperar la rama. Asegúrate de tener permisos.";
1543                    if (window.showToast) window.showToast(errMsg, 'error');
1544                } finally {
1545                    if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
1546                }
1547            });
1548        }
1549    }
1550
1551    async checkUserNotifications(user) {
1552        if (!user || !user.uid) return;
1553        const db = getDb();
1554        if (!db) return;
1555
1556        try {
1557            const { collection, query, where, getDocs, updateDoc, doc }
1557 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1558            const notifQuery = query(
1559                collection(db, 'user_notifications'),
1560                where('userId', '==', user.uid),
1561                where('read', '==', false)
1562            );
1563            const snap = await getDocs(notifQuery);
1564            if (!snap.empty) {
1565                const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1566                const notifications = snap.docs.map(d => ({ id: d.id, ...d.data() }));
1567
1568                // Mark them as read in Firestore
1569                for (const notif of notifications) {
1570                    try {
1571                        await updateDoc(doc(db, 'user_notifications', notif.id), { read: true });
1572                    } catch (e) {
1573                        console.warn("Could not mark notification as read:", e);
1574                    }
1575                }
1576
1577                // Display notification to the user
1578                if (notifications.length === 1) {
1579                    const notif = notifications[0];
1580                    const title = notif.title || (isEn ? "Ownership Notification" : "Aviso de Propiedad");
1581                    if (typeof window.openInfoModal === 'function') {
1582                        window.openInfoModal(title, notif.message);
1583                    } else if (window.showToast) {
1584                        window.showToast(notif.message, notif.type === 'branch_reclaimed' ? 'warning' : 'info');
1585                    }
1586                } else {
1587                    const combinedTitle = isEn ? "Ownership Notifications" : "Avisos de Propiedad y Delegación";
1588                    const combinedMessage = notifications.map(n => `📢 ${n.title}:\n${n.message}`).join('\n\n──────────────\n\n');
1589                    if (typeof window.openInfoModal === 'function') {
1590                        window.openInfoModal(combinedTitle, combinedMessage);
1591                    } else if (window.showToast) {
1592                        window.showToast(combinedMessage, 'info');
1593                    }
1594                }
1595            }
1596        } catch (e) {
1597            console.warn("Error checking user notifications:", e);
1598        }
1599
1600        // Always check for pending proposals / unapproved versions
1601        await this.checkPendingApprovals(user);
1602    }
1603
1604    async checkPendingApprovals(user) {
1605        if (!user || !user.uid) return;
1606        const db = getDb();
1607        if (!db) return;
1608
1609        try {
1610            const { collection, query, where, getDocs, doc, getDoc } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
1611            const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1612            const pendingList = [];
1613
1614            const isAdminEmail = checkAdminEmail;
1615            const isSuperAdmin = (user.uid === 'admin' || isAdminEmail(user.email));
1616
1617            // 1. Check pending proposals in 'proposals' collection
1618            try {
1619                const proposalsQ = isSuperAdmin
1620                    ? query(collection(db, 'proposals'), where('status', '==', 'pending'))
1621                    : query(collection(db, 'proposals'), where('targetOwnerId', '==', user.uid), where('status', '==', 'pending'));
1622                
1623                const propSnap = await getDocs(proposalsQ);
1624                propSnap.docs.forEach(d => {
1625                    const data = d.data();
1626                    const typeLabel = data.type === 'overflow_song_version' 
1627                        ? (isEn ? 'Versión extra (cupo 5/5 lleno)' : 'Versión extra (cupo 5/5 lleno)')
1628                        : (data.type === 'new_song' ? (isEn ? 'Nueva canción propuesta' : 'Nueva canción propuesta') : (isEn ? 'Propuesta' : 'Propuesta'));
1629                    const author = data.submittedByName || 'Comunidad';
1630                    pendingList.push({
1631                        type: 'proposal',
1632                        id: d.id,
1633                        title: typeLabel,
1634                        author: author,
1635                        songName: data.targetNodeId || ''
1636                    });
1637                });
1638            } catch (pe) {
1639                console.warn("Could not query proposals collection:", pe);
1640            }
1641
1642            // 2. Check pending versions in 'versions' collection directly
1643            try {
1644                const versionsQ = query(
1645                    collection(db, 'versions'),
1646                    where('status', '==', 'pending')
1647                );
1648                const versSnap = await getDocs(versionsQ);
1649
1650                if (!versSnap.empty) {
1651                    for (const vDoc of versSnap.docs) {
1652                        const vData = vDoc.data();
1653                        if (!vData.songId) continue;
1654
1655                        let songTitle = vData.songId;
1656                        let bandTitle = '';
1657                        let effectiveOwner = null;
1658
1659                        // Check in active library tree if present
1660                        if (window.fullLibraryData) {
1661                            for (const b of window.fullLibraryData) {
1662                                for (const a of (b.children || [])) {
1663                                    for (const s of (a.children || [])) {
1664                                        if (s.id === vData.songId) {
1665                                            songTitle = s.name || songTitle;
1666                                            bandTitle = b.name || '';
1667                                            effectiveOwner = s.ownerId || a.ownerId || b.ownerId;
1668                                            break;
1669                                        }
1670                                    }
1671                                    if (effectiveOwner) break;
1672                                }
1673                                if (effectiveOwner) break;
1674                            }
1675                        }
1676
1677                        // If not in library cache, fetch song doc from Firestore
1678                        if (!effectiveOwner) {
1679                            try {
1680                                const sSnap = await getDoc(doc(db, 'songs', vData.songId));
1681                                if (sSnap.exists()) {
1682                                    const sData = sSnap.data();
1683                                    effectiveOwner = sData.ownerId;
1684                                    songTitle = sData.name || songTitle;
1685                                    if (sData.bandId) bandTitle = sData.bandId;
1686                                }
1687                            } catch (se) {
1688                                console.warn("Could not fetch song doc for pending version:", se);
1689                            }
1690                        }
1691
1692                        if (isSuperAdmin || (effectiveOwner && effectiveOwner === user.uid)) {
1693                            pendingList.push({
1694                                type: 'version',
1695                                id: vDoc.id,
1696                                songName: songTitle,
1697                                bandName: bandTitle,
1698                                author: vData.label || (vData.authorId ? `Usuario ${vData.authorId.substring(0, 6)}` : 'Colaborador'),
1699                                title: bandTitle ? `${bandTitle} - ${songTitle}` : songTitle
1700                            });
1701                        }
1702                    }
1703                }
1704            } catch (ve) {
1705                console.warn("Could not query pending versions:", ve);
1706            }
1707
1708            if (pendingList.length > 0) {
1709                // Check debounce in sessionStorage
1710                const signature = `${user.uid}_${pendingList.map(p => p.id || p.songName).join('|')}`;
1711                if (sessionStorage.getItem('last_seen_pending_signature') === signature) {
1712                    return;
1713                }
1714                sessionStorage.setItem('last_seen_pending_signature', signature);
1715
1716                const modalTitle = isEn 
1717                    ? `Pending Approvals (${pendingList.length})` 
1718                    : `Aprobaciones Pendientes (${pendingList.length})`;
1719
1720                let messageLines = [
1721                    isEn 
1722                        ? `You have ${pendingList.length} pending proposal(s) or version(s) waiting for your review:\n`
1723                        : `Tienes ${pendingList.length} propuesta(s) o versión(es) pendiente(s) de moderación:\n`
1724                ];
1725
1726                pendingList.forEach((item, idx) => {
1727                    if (item.bandName && item.songName) {
1728                        messageLines.push(`${idx + 1}. 🎵 ${item.bandName} → "${item.songName}" (${item.author})`);
1729                    } else {
1730                        messageLines.push(`${idx + 1}. 📄 ${item.title} (${item.author})`);
1731                    }
1732                });
1733
1734                messageLines.push(isEn 
1735                    ? "\nOpen any song in the navigation tree to test the chords and select 'Set as Master', 'Approve Variant', or 'Discard'."
1736                    : "\nAbre la canción en el catálogo para revisar los acordes y elegir 'Hacer Master', 'Aprobar Variante' o 'Descartar'.");
1737
1738                const fullMessage = messageLines.join('\n');
1739
1740                if (typeof window.openInfoModal === 'function') {
1741                    setTimeout(() => {
1742                        window.openInfoModal(modalTitle, fullMessage);
1743                    }, 500);
1744                } else if (window.showToast) {
1745                    window.showToast(fullMessage, 'info');
1746                }
1747            }
1748        } catch (e) {
1749            console.warn("Error checking pending approvals:", e);
1750        }
1751    }
1752
1753    populateVersionSelector(preferredVersionId = null) {
1754        const select = document.getElementById('version-select');
1755        if (!select) return;
1756
1757        select.innerHTML = '';
1758
1759        const auth = getFirebaseAuth();
1760        const currentUser = auth?.currentUser;
1761        const currentUid = currentUser?.uid;
1762        const currentDisplayName = (currentUser?.displayName || '').trim().toLowerCase();
1763        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1764
1765        this.versions.forEach(v => {
1766            const opt = document.createElement('option');
1767            opt.value = v.id;
1768            
1769            // Clean any existing historical tags
1770            let baseLabel = (v.label || 'Versión')
1771                .replace(/\s*\((Master|Variante|Propuesta)\)/gi, '')
1772                .replace(/★|⭐/g, '')
1773                .trim();
1774
1775            if (v.status === 'approved') {
1776                if (baseLabel.toLowerCase().startsWith('propuesta por')) {
1777                    baseLabel = baseLabel.replace(/^propuesta por/i, 'Realizada por');
1778                } else if (baseLabel.toLowerCase().startsWith('propuesta (')) {
1779                    baseLabel = baseLabel.replace(/^propuesta \(/i, '').replace(/\)$/, '').trim();
1780                } else if (baseLabel.toLowerCase().startsWith('propuesta:')) {
1781                    baseLabel = baseLabel.replace(/^propuesta:\s*/i, '').trim();
1782                }
1783            }
1784
1785            // Check if current user is the author/creator of this version
1786            const isAdminEmail = checkAdminEmail;
1787            const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email));
1788            
1789            const versionAuthorUid = v.authorId || v.ownerId || v.userId;
1790            let isUserAuthor = false;
1791
1792            if (currentUid) {
1793                if (versionAuthorUid) {
1794                    if (versionAuthorUid === currentUid) {
1795                        isUserAuthor = true;
1796                    } else if (versionAuthorUid === 'admin' && isUserActuallyTheAdmin) {
1797                        isUserAuthor = true;
1798                    }
1799                } else if (isUserActuallyTheAdmin) {
1800                    isUserAuthor = true;
1801                }
1802            }
1803
1804            if (isUserAuthor && !baseLabel.toLowerCase().includes('(tú)') && !baseLabel.toLowerCase().includes('(you)')) {
1805                baseLabel = `${baseLabel} (${isEn ? 'You' : 'Tú'})`;
1806            }
1807
1808            const isMaster = (v.id === this.currentMasterVersionId) || 
1809                             (!this.currentMasterVersionId && v.id.endsWith('_v1'));
1810
1811            let displayLabel = '';
1812            if (isMaster) {
1813                displayLabel = `${baseLabel} (Master) ★`;
1814            } else if (v.status === 'approved') {
1815                displayLabel = `${baseLabel} (Variante)`;
1816            } else {
1817                displayLabel = baseLabel.toLowerCase().startsWith('propuesta') ? baseLabel : `Propuesta (${baseLabel})`;
1818            }
1819
1820            opt.textContent = displayLabel;
1821            select.appendChild(opt);
1822        });
1823
1824        select.onchange = (e) => {
1825            const version = this.versions.find(v => v.id === e.target.value);
1826            if (version) {
1827                this.displayVersion(version);
1828            }
1829        };
1830
1831        if (this.versions.length > 0) {
1832            let active = null;
1833            if (preferredVersionId) {
1834                active = this.versions.find(v => v.id === preferredVersionId);
1835            }
1836            if (!active && this.currentMasterVersionId) {
1837                active = this.versions.find(v => v.id === this.currentMasterVersionId);
1838            }
1839            if (!active) {
1840                active = this.versions.find(v => v.status === 'approved') || this.versions[0];
1841            }
1842            select.value = active.id;
1843            this.updateStarsUI(active.starsAvg || 0, active.ratingsCount || 0, active.id);
1844        }
1845    }
1846
1847    displayVersion(version) {
1848        if (!version) return;
1849
1850        let updatedInParsedData = false;
1851
1852        // Prepare text to parse: ensure it has enclosing (Band) and (Album) if not present
1853        let textToParse = version.content || '';
1854        if (!textToParse.match(/^\s*\((Band|Playlist)\)/im)) {
1855            if (textToParse.match(/^\s*\(Album\)/im)) {
1856                textToParse = `(Band) ${this.grandparentName || 'Band'}\n` + textToParse;
1857            } else if (textToParse.match(/^\s*\(Song\)/im)) {
1858                textToParse = `(Band) ${this.grandparentName || 'Band'}\n(Album) ${this.parentName || 'Album'}\n` + textToParse;
1859            } else {
1860                textToParse = `(Band) ${this.grandparentName || 'Band'}\n(Album) ${this.parentName || 'Album'}\n(Song) ${this.currentNodeName || 'Song'}\n` + textToParse;
1861            }
1862        }
1863
1864        let parsedSong = null;
1865        if (typeof window.parseChords === 'function') {
1866            const tempParsed = window.parseChords(textToParse);
1867            if (tempParsed && tempParsed.length > 0) {
1868                for (const b of tempParsed) {
1869                    for (const a of (b.albums || [])) {
1870                        for (const s of (a.songs || [])) {
1871                            if (s) { parsedSong = s; break; }
1872                        }
1873                        if (parsedSong) break;
1874                    }
1875                    if (parsedSong) break;
1876                }
1877            }
1878        }
1879
1880        if (window.parsedData && window.parsedData.length > 0 && this.currentNodeName && parsedSong) {
1881            const targetSongName = (this.currentNodeName || '').trim().toLowerCase();
1882            const targetAlbumName = this.parentName ? this.parentName.trim().toLowerCase() : null;
1883            const targetBandName = this.grandparentName ? this.grandparentName.trim().toLowerCase() : null;
1884
1885            for (let b = 0; b < window.parsedData.length; b++) {
1886                const band = window.parsedData[b];
1887                if (targetBandName && (band.name || '').trim().toLowerCase() !== targetBandName) continue;
1888
1889                for (let a = 0; a < (band.albums || []).length; a++) {
1890                    const album = band.albums[a];
1891                    if (targetAlbumName && (album.name || '').trim().toLowerCase() !== targetAlbumName) continue;
1892
1893                    for (let s = 0; s < (album.songs || []).length; s++) {
1894                        const song = album.songs[s];
1895                        if ((song.name || '').trim().toLowerCase() === targetSongName) {
1896                            song.tonalityGroups = parsedSong.tonalityGroups;
1897                            song.lyrics = parsedSong.lyrics;
1898                            song._originalLyrics = parsedSong._originalLyrics;
1899                            song.transpose = parsedSong.transpose || 0;
1900                            updatedInParsedData = true;
1901                            break;
1902                        }
1903                    }
1904                    if (updatedInParsedData) break;
1905                }
1906                if (updatedInParsedData) break;
1907            }
1908        }
1909
1910        const chordInput = document.getElementById('chordInput');
1911        if (chordInput) {
1912            const treeState = typeof window.captureTreeState === 'function' ? window.captureTreeState() : null;
1913
1914            if (updatedInParsedData && typeof window.generateRawText === 'function') {
1915                chordInput.value = window.generateRawText(window.parsedData);
1916            } else if ((version.content || '').match(/^\s*\((Band|Playlist)\)/im)) {
1917                chordInput.value = version.content;
1918            } else {
1919                chordInput.value = textToParse;
1920            }
1921
1922            // Trigger the viewer update (chordviewer.js)
1923            if (typeof window.processChords === 'function') {
1924                window.processChords();
1925                window.isDirty = false; // Reset dirty state as we just loaded a version
1926                window._lastCleanEditorText = chordInput.value || '';
1927            }
1928
1929            if (treeState && typeof window.restoreTreeState === 'function') {
1930                window.restoreTreeState(treeState);
1931            } else if (typeof window.expandPathToElement === 'function') {
1932                setTimeout(() => window.expandPathToElement('song', 0, 0, 0), 50);
1933            }
1934        }
1935        this.updateStarsUI(version.starsAvg || 0, version.ratingsCount || 0, version.id);
1936    }
1937
1938    updateStarsUI(avg, count, currentVersionId) {
1939        const avgEl = document.getElementById('version-stars-avg');
1940        const countEl = document.getElementById('version-ratings-count');
1941        const starsRow = document.getElementById('stars-row');
1942        const starContainer = document.getElementById('star-rating-display');
1943        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
1944
1945        const avgNum = typeof avg === 'number' ? avg : (parseFloat(avg) || 3.0);
1946        const countNum = typeof count === 'number' ? count : (parseInt(count, 10) || 1);
1947
1948        if (avgEl) avgEl.textContent = `${avgNum.toFixed(1)}/5`;
1949        if (countEl) countEl.textContent = `(${countNum})`;
1950
1951        const tooltip = isEn 
1952            ? `Average rating: ${avgNum.toFixed(1)}/5 (${countNum} total rating${countNum === 1 ? '' : 's'}). Click stars to submit your rating.`
1953            : `Valoración media: ${avgNum.toFixed(1)}/5 (${countNum} valoración${countNum === 1 ? '' : 'es'} en total). Haz clic en las estrellas para puntuar.`;
1954
1955        if (starContainer) starContainer.setAttribute('title', tooltip);
1956
1957        const auth = getFirebaseAuth() || (window.getFirebaseAuth && window.getFirebaseAuth());
1958        const currentUser = auth?.currentUser || window.currentUser;
1959        const currentUid = currentUser?.uid;
1960        const isAdminEmail = checkAdminEmail;
1961        const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email));
1962
1963        const activeVer = this.versions?.find(v => v.id === currentVersionId) || this.versions?.[0];
1964        const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId;
1965        const isSelfVersion = !!(currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin)));
1966
1967        if (starsRow) {
1968            starsRow.innerHTML = '';
1969            const rounded = Math.round(avgNum);
1970
1971            const renderStars = (activeScore) => {
1972                starsRow.querySelectorAll('span.star-item').forEach((star, idx) => {
1973                    const starIndex = idx + 1;
1974                    if (starIndex <= activeScore) {
1975                        star.textContent = '★';
1976                        star.className = isSelfVersion
1977                            ? 'star-item px-0.5 text-yellow-500 cursor-default'
1978                            : 'star-item cursor-pointer transition-transform hover:scale-125 px-0.5 text-yellow-500 hover:text-yellow-400';
1979                    } else {
1980                        star.textContent = '☆';
1981                        star.className = isSelfVersion
1982                            ? 'star-item px-0.5 text-gray-300 dark:text-gray-600 cursor-default'
1983                            : 'star-item cursor-pointer transition-transform hover:scale-125 px-0.5 text-gray-300 dark:text-gray-600 hover:text-yellow-400';
1984                    }
1985                });
1986            };
1987
1988            for (let i = 1; i <= 5; i++) {
1989                const s = document.createElement('span');
1990                s.textContent = i <= rounded ? '★' : '☆';
1991                s.style.fontSize = '1.15rem';
1992
1993                if (isSelfVersion) {
1994                    s.className = `star-item px-0.5 ${i <= rounded ? 'text-yellow-500' : 'text-gray-300 dark:text-gray-600'} cursor-default`;
1995                    s.title = isEn ? `Your version (Average rating: ${avgNum.toFixed(1)}/5)` : `Tu versión (Valoración media: ${avgNum.toFixed(1)}/5)`;
1996                    s.onclick = (e) => {
1997                        e.stopPropagation();
1998                        if (window.showToast) window.showToast(isEn ? "You cannot rate your own version." : "No puedes valorar tu propia versión.", "info");
1999                    };
2000                } else {
2001                    s.className = `star-item cursor-pointer transition-transform hover:scale-125 px-0.5 ${i <= rounded ? 'text-yellow-500' : 'text-gray-300 dark:text-gray-600'} hover:text-yellow-400`;
2002                    s.title = isEn ? `Rate ${i} of 5 stars` : `Puntuar con ${i} de 5 estrellas`;
2003
2004                    // Hover preview
2005                    s.onmouseenter = () => renderStars(i);
2006
2007                    // Click to vote
2008                    s.onclick = (e) => {
2009                        e.stopPropagation();
2010                        const effectiveVerId = currentVersionId || this.currentMasterVersionId || document.getElementById('version-select')?.value || (this.currentSongId ? `${this.currentSongId}_v1` : 'master_v1');
2011                        this.submitRating(effectiveVerId, i);
2012                    };
2013                }
2014
2015                starsRow.appendChild(s);
2016            }
2017
2018            if (!isSelfVersion) {
2019                starsRow.onmouseleave = () => renderStars(rounded);
2020            }
2021        }
2022
2023        this.checkOwnershipAndShowControls(currentVersionId);
2024    }
2025
2026    checkOwnershipAndShowControls(versionId) {
2027        const auth = getFirebaseAuth();
2028        const currentUser = auth?.currentUser;
2029        const isAdminEmail = checkAdminEmail;
2030        const isSuperAdmin = (currentUser?.uid === 'admin' || isAdminEmail(currentUser?.email));
2031        const isOwner = !!(currentUser?.uid && this.currentOwnerId && currentUser.uid === this.currentOwnerId);
2032        const canModerate = isSuperAdmin || isOwner;
2033
2034        const container = document.getElementById('version-selector-container');
2035        if (!container) return;
2036
2037        // Limpiar controles previos
2038        container.querySelectorAll('.admin-ctrl-btn').forEach(b => b.remove());
2039
2040        const saveBtn = document.getElementById('save-node-btn');
2041        const deleteBtn = document.getElementById('delete-node-btn');
2042        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2043
2044        const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 
2045                           (sessionStorage.getItem('chordlib_active_tab') === 'personal');
2046
2047        if (!isPersonal) {
2048            const currentVer = this.versions.find(v => v.id === versionId) || this.versions?.[0];
2049            const verAuthorUid = currentVer?.authorId || currentVer?.ownerId || currentVer?.userId;
2050            const isAuthorOfActiveVersion = !!(currentUser?.uid && (verAuthorUid === currentUser.uid || (verAuthorUid === 'admin' && isSuperAdmin)));
2051            const userHasExistingVersion = !!(currentUser?.uid && this.versions?.some(v => (v.authorId === currentUser.uid || v.ownerId === currentUser.uid || v.userId === currentUser.uid)));
2052
2053            const hasWrite = (isSuperAdmin || isOwner || isAuthorOfActiveVersion || userHasExistingVersion);
2054            const canDelete = (isSuperAdmin || isOwner || isAuthorOfActiveVersion);
2055
2056            if (saveBtn) {
2057                saveBtn.disabled = false;
2058                saveBtn.className = hasWrite 
2059                    ? 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'
2060                    : 'px-2.5 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer';
2061                saveBtn.title = hasWrite 
2062                    ? (isEn ? 'Save / update changes to your version' : 'Guardar y actualizar cambios en tu versión')
2063                    : (isEn ? 'Submit chord version proposal to owner/admin' : 'Proponer una nueva versión de acordes al dueño/administrador');
2064                saveBtn.innerHTML = `<span>${hasWrite ? '💾' : '💡'}</span> <span>${hasWrite ? (isEn ? 'Save' : 'Grabar') : (isEn ? 'Propose version' : 'Proponer versión')}</span>`;
2065            }
2066
2067            if (deleteBtn) {
2068                deleteBtn.disabled = false;
2069                deleteBtn.className = (canDelete && (versionId || this.currentSongId))
2070                    ? 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'
2071                    : 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-70 cursor-pointer';
2072                deleteBtn.title = (canDelete && (versionId || this.currentSongId))
2073                    ? (isEn ? 'Delete version from official catalog' : 'Eliminar versión del catálogo oficial')
2074                    : (isEn ? 'No permission to delete (only version author, branch owner or admin)' : 'Sin permiso para borrar (solo autor de la versión, dueño o admin)');
2075                deleteBtn.innerHTML = `<span>🗑️</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`;
2076            }
2077        }
2078
2079        if (canModerate) {
2080            const currentVersion = this.versions.find(v => v.id === versionId);
2081            if (!currentVersion) return;
2082
2083            const isMaster = (versionId === this.currentMasterVersionId) || 
2084                             (!this.currentMasterVersionId && versionId.endsWith('_v1'));
2085
2086            const btnClass = "admin-ctrl-btn text-[10px] px-2.5 py-1 rounded-lg font-bold transition ml-2 cursor-pointer shadow-sm flex items-center space-x-1";
2087
2088            if (isMaster) {
2089                // Es el Master oficial actual
2090                const masterBadge = document.createElement('button');
2091                masterBadge.className = 'admin-ctrl-btn ml-2 text-[10px] bg-emerald-600 text-white px-2.5 py-1 rounded-lg font-bold cursor-default shadow-sm';
2092                masterBadge.textContent = '★ Master Actual';
2093                masterBadge.disabled = true;
2094                container.appendChild(masterBadge);
2095            } else if (currentVersion.status === 'approved') {
2096                // Es una variante aprobada (no es Master) -> El dueño/admin puede promoverla a Master voluntariamente en cualquier momento
2097                const masterBtn = document.createElement('button');
2098                masterBtn.className = `${btnClass} bg-amber-500 hover:bg-amber-600 text-white`;
2099                masterBtn.innerHTML = '<span>★</span> <span>Hacer Master</span>';
2100                masterBtn.title = 'Establecer esta versión como la Master oficial para todos los usuarios';
2101                masterBtn.onclick = () => {
2102                    const msg = `¿Deseas establecer esta versión ("${currentVersion.label || 'Variante'}") como la nueva versión Master oficial de la canción?`;
2103                    if (typeof window.openConfirmModal === 'function') {
2104                        window.openConfirmModal(msg, () => this.setAsMaster(versionId));
2105                    } else {
2106                        this.setAsMaster(versionId);
2107                    }
2108                };
2109                container.appendChild(masterBtn);
2110
2111                const deleteVarBtn = document.createElement('button');
2112                deleteVarBtn.className = `${btnClass} bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-950/60 dark:text-rose-300`;
2113                deleteVarBtn.textContent = 'Eliminar Variante';
2114                deleteVarBtn.title = 'Eliminar esta variante alternativa permanentemente';
2115                deleteVarBtn.onclick = () => {
2116                    const msg = '⚠️ ¿Seguro que deseas eliminar esta variante permanentemente?';
2117                    if (typeof window.openConfirmModal === 'function') {
2118                        window.openConfirmModal(msg, () => this.rejectProposal(versionId));
2119                    }
2120                };
2121                container.appendChild(deleteVarBtn);
2122            } else {
2123                // Es una propuesta pendiente
2124                const masterBtn = document.createElement('button');
2125                masterBtn.className = `${btnClass} bg-emerald-600 hover:bg-emerald-700 text-white`;
2126                masterBtn.textContent = 'Hacer Master';
2127                masterBtn.title = 'Aprobar y convertir directamente en la versión Master oficial';
2128                masterBtn.onclick = () => this.setAsMaster(versionId);
2129                container.appendChild(masterBtn);
2130
2131                const varBtn = document.createElement('button');
2132                varBtn.className = `${btnClass} bg-blue-600 hover:bg-blue-700 text-white`;
2133                varBtn.textContent = 'Aprobar Variante';
2134                varBtn.title = 'Aprobar manteniéndola como alternativa pública no-oficial';
2135                varBtn.onclick = () => this.approveVariant(versionId);
2136                container.appendChild(varBtn);
2137
2138                const rejectBtn = document.createElement('button');
2139                rejectBtn.className = `${btnClass} bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-950/60 dark:text-rose-300`;
2140                rejectBtn.textContent = 'Descartar';
2141                rejectBtn.title = 'Rechazar y eliminar esta propuesta permanentemente';
2142                rejectBtn.onclick = () => {
2143                    const msg = '⚠️ ¿Seguro que deseas rechazar y eliminar esta propuesta permanentemente?';
2144                    if (typeof window.openConfirmModal === 'function') {
2145                        window.openConfirmModal(msg, () => this.rejectProposal(versionId));
2146                    }
2147                };
2148                container.appendChild(rejectBtn);
2149            }
2150        }
2151    }
2152
2153    async setAsMaster(versionId) {
2154        const db = getDb();
2155        if (!db || !this.currentSongId) return;
2156
2157        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2158        if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Setting master version…' : 'Estableciendo como Master…');
2159
2160        try {
2161            const { updateDoc, getDocs, collection, query, where, writeBatch, doc } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
2162            const batch = writeBatch(db);
2163
2164            // 1. Update Song metadata to point to this version
2165            const songRef = doc(db, 'songs', this.currentSongId);
2166            const version = this.versions.find(v => v.id === versionId);
2167
2168            let cleanSongContent = version?.content || '';
2169            const songIdx = cleanSongContent.search(/^\s*\(Song\)/im);
2170            if (songIdx !== -1) {
2171                cleanSongContent = cleanSongContent.substring(songIdx);
2172            }
2173
2174            batch.update(songRef, {
2175                masterVersionId: versionId,
2176                content: cleanSongContent,
2177                hasChords: !!(cleanSongContent && window.detectChordsInText && window.detectChordsInText(cleanSongContent))
2178            });
2179
2180            // 2. Update version label and status
2181            const versionRef = doc(db, 'versions', versionId);
2182            let newLabel = version?.label || '';
2183            if (newLabel.startsWith('Propuesta por ')) {
2184                newLabel = newLabel.replace('Propuesta por ', 'Realizada por ');
2185            }
2186            batch.update(versionRef, { status: 'approved', label: newLabel });
2187
2188            // 3. Bump sync metadata
2189            batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true });
2190
2191            await batch.commit();
2192
2193            if (window.showToast) window.showToast("Versión establecida como Master.", 'success');
2194
2195            // Force full tree refresh
2196            if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0);
2197            if (typeof window.triggerSync === 'function') await window.triggerSync(true);
2198
2199            await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true, versionId);
2200        } catch (error) {
2201            console.error("Error setting master version:", error);
2202            if (window.showToast) window.showToast("No tienes permiso o hubo un error.", 'error');
2203        } finally {
2204            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
2205        }
2206    }
2207
2208    async approveVariant(versionId) {
2209        const db = getDb();
2210        if (!db) return;
2211
2212        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2213        if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Approving variant…' : 'Aprobando variante…');
2214
2215        try {
2216            const { updateDoc, doc, writeBatch }
2216 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
2217            const versionRef = doc(db, 'versions', versionId);
2218            const version = this.versions.find(v => v.id === versionId);
2219
2220            let newLabel = version?.label || '';
2221            if (newLabel.startsWith('Propuesta por ')) {
2222                newLabel = newLabel.replace('Propuesta por ', 'Realizada por ');
2223            }
2224            await updateDoc(versionRef, { status: 'approved', label: newLabel });
2225
2226            const batch = writeBatch(db);
2227            batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true });
2228            await batch.commit();
2229
2230            if (window.showToast) window.showToast("Variante aprobada para visualización pública.", 'success');
2231
2232            if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0);
2233            if (typeof window.triggerSync === 'function') await window.triggerSync(true);
2234
2235            await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true, versionId);
2236        } catch (error) {
2237            console.error("Error approving variant:", error);
2238            if (window.showToast) window.showToast("Hubo un error al aprobar la variante.", 'error');
2239        } finally {
2240            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
2241        }
2242    }
2243
2244    loadParentNodeAfterSongDeletion() {
2245        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2246        const pAlbum = (this.parentName && typeof window.findNodeInLibrary === 'function') 
2247            ? window.findNodeInLibrary('album', this.parentName, this.grandparentName) 
2248            : null;
2249        if (pAlbum && typeof window.loadSongIntoViewer === 'function') {
2250            window.loadSongIntoViewer(pAlbum.copyText, {
2251                type: 'album',
2252                name: pAlbum.name,
2253                parentName: pAlbum.parentName,
2254                grandparentName: pAlbum.grandparentName,
2255                library: 'official'
2256            });
2257            if (typeof selectLibraryNode === 'function') {
2258                selectLibraryNode('album', pAlbum.name, pAlbum.parentName, pAlbum.grandparentName);
2259            }
2260            this.updateOwnershipUI(pAlbum.ownerId, 'album', pAlbum.id, pAlbum.name, pAlbum.parentName, pAlbum.grandparentName);
2261            return;
2262        }
2263
2264        const pBand = (this.grandparentName && typeof window.findNodeInLibrary === 'function') 
2265            ? window.findNodeInLibrary('band', this.grandparentName) 
2266            : null;
2267        if (pBand && typeof window.loadSongIntoViewer === 'function') {
2268            window.loadSongIntoViewer(pBand.copyText, {
2269                type: 'band',
2270                name: pBand.name,
2271                library: 'official'
2272            });
2273            if (typeof selectLibraryNode === 'function') {
2274                selectLibraryNode('band', pBand.name);
2275            }
2276            this.updateOwnershipUI(pBand.ownerId, 'band', pBand.id, pBand.name, null, null);
2277            return;
2278        }
2279
2280        // Si no se encuentra rama padre, limpiar editor
2281        const chordInput = document.getElementById('chordInput');
2282        if (chordInput) {
2283            chordInput.value = '';
2284            window._lastCleanEditorText = '';
2285            window.isDirty = false;
2286            if (typeof window.processChords === 'function') window.processChords();
2287        }
2288        this.resetUI();
2289    }
2290
2291    async rejectProposal(versionId) {
2292        const db = getDb();
2293        if (!db) return;
2294
2295        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2296        if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Deleting version…' : 'Eliminando versión…');
2297
2298        try {
2299            const { deleteDoc, doc, getDocs, collection, query, where, writeBatch } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
2300            await deleteDoc(doc(db, 'versions', versionId));
2301
2302            // Cleanup: Si era la única versión y se rechaza, borrar también la metadata de la canción
2303            const versionsLeftSnap = await getDocs(query(collection(db, 'versions'), where('songId', '==', this.currentSongId)));
2304            const isSongDeleted = versionsLeftSnap.empty;
2305            if (isSongDeleted) {
2306                await deleteDoc(doc(db, 'songs', this.currentSongId));
2307            }
2308
2309            const batch = writeBatch(db);
2310            batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true });
2311            await batch.commit();
2312
2313            if (window.showToast) window.showToast(isEn ? "Version deleted successfully." : "Versión eliminada.", 'success');
2314
2315            if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0);
2316            if (typeof window.triggerSync === 'function') await window.triggerSync(true);
2317
2318            if (isSongDeleted) {
2319                this.loadParentNodeAfterSongDeletion();
2320            } else {
2321                await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true);
2322                if (this.versions.length > 0) {
2323                    const active = (this.currentMasterVersionId && this.versions.find(v => v.id === this.currentMasterVersionId)) ||
2324                                   this.versions.find(v => v.status === 'approved') ||
2325                                   this.versions[0];
2326                    if (active) {
2327                        this.displayVersion(active);
2328                    }
2329                }
2330            }
2331        } catch (error) {
2332            console.error("Error rejecting proposal:", error);
2333            if (window.showToast) window.showToast(isEn ? "Error deleting version." : "Hubo un error al eliminar la versión.", 'error');
2334        } finally {
2335            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
2336        }
2337    }
2338
2339    async submitRating(versionId, score) {
2340        if (!versionId) {
2341            versionId = this.currentMasterVersionId || document.getElementById('version-select')?.value || (this.currentSongId ? `${this.currentSongId}_v1` : 'master_v1');
2342        }
2343        if (!versionId) return;
2344        let db = getDb();
2345        if (!db) {
2346            try {
2347                const { initFirebase } = await import('./firebase_manager.js');
2348                await initFirebase();
2349                db = getDb();
2350            } catch (e) {
2351                console.warn("Could not auto-init firebase in submitRating:", e);
2352            }
2353        }
2354        if (!db) {
2355            if (window.showToast) window.showToast("Error de conexión a la base de datos.", 'error');
2356            return;
2357        }
2358
2359        const auth = getFirebaseAuth() || (window.getFirebaseAuth && window.getFirebaseAuth());
2360        const currentUser = auth?.currentUser || window.currentUser;
2361        const currentUid = currentUser?.uid;
2362        const isEn = typeof getLanguage === 'function' && getLanguage() === 'en';
2363
2364        const isAdminEmail = checkAdminEmail;
2365        const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email));
2366
2367        const activeVer = this.versions?.find(v => v.id === versionId || v.id === String(versionId).replace(/[\/\s#$[\]]/g, '_'));
2368        const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId;
2369        const isSelfVote = !!(currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin)));
2370
2371        if (isSelfVote) {
2372            if (window.showToast) {
2373                window.showToast(isEn ? "You cannot rate your own version." : "No puedes valorar tu propia versión.", "info");
2374            }
2375            return;
2376        }
2377
2378        let userId = currentUid;
2379        if (!userId) {
2380            userId = localStorage.getItem('chordlib_anon_voter_id');
2381            if (!userId) {
2382                userId = 'anon_' + Math.random().toString(36).substring(2, 11);
2383                localStorage.setItem('chordlib_anon_voter_id', userId);
2384            }
2385        }
2386
2387        const safeVersionId = String(versionId).replace(/[\/\s#$[\]]/g, '_');
2388        const safeRatingId = `${userId}_${safeVersionId}`.replace(/[\/\s#$[\]]/g, '_');
2389
2390        if (typeof window.showLoadingModal === 'function') {
2391            window.showLoadingModal(isEn ? 'Submitting rating…' : 'Guardando valoración…');
2392        }
2393
2394        try {
2395            const { doc, getDoc, setDoc, serverTimestamp }
2395 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js");
2396            const ratingRef = doc(db, 'ratings', safeRatingId);
2397            let prevRatingSnap = null;
2398            try {
2399                prevRatingSnap = await getDoc(ratingRef);
2400            } catch (snapErr) {
2401                console.warn("Could not fetch previous rating doc:", snapErr);
2402            }
2403
2404            const prevExists = !!(prevRatingSnap && (typeof prevRatingSnap.exists === 'function' ? prevRatingSnap.exists() : prevRatingSnap.exists));
2405            let oldScore = null;
2406            let existingCreatedAt = null;
2407
2408            if (prevExists && typeof prevRatingSnap.data === 'function') {
2409                const pData = prevRatingSnap.data();
2410                oldScore = (pData && typeof pData.score === 'number') ? pData.score : (parseFloat(pData?.score) || null);
2411                existingCreatedAt = pData?.createdAt || null;
2412            }
2413
2414            // 1. Save / update individual user rating
2415            const ratingPayload = {
2416                userId: userId,
2417                versionId: safeVersionId,
2418                score: Number(score),
2419                updatedAt: serverTimestamp()
2420            };
2421            if (existingCreatedAt) {
2422                ratingPayload.createdAt = existingCreatedAt;
2423            } else {
2424                ratingPayload.createdAt = serverTimestamp();
2425            }
2426
2427            await setDoc(ratingRef, ratingPayload, { merge: true });
2428
2429            // 2. Fetch version document to recalculate average accurately
2430            const versionRef = doc(db, 'versions', safeVersionId);
2431            let vSnap = null;
2432            try {
2433                vSnap = await getDoc(versionRef);
2434            } catch (vErr) {
2435                console.warn("Could not fetch version doc:", vErr);
2436            }
2437
2438            const vExists = !!(vSnap && (typeof vSnap.exists === 'function' ? vSnap.exists() : vSnap.exists));
2439            let newAvg = Number(score);
2440            let newCount = 1;
2441
2442            if (vExists && typeof vSnap.data === 'function') {
2443                const vData = vSnap.data() || {};
2444                const currentCount = Number(vData.ratingsCount) || 1;
2445                const currentAvg = Number(vData.starsAvg) || 3.0;
2446
2447                if (oldScore !== null) {
2448                    // Update of previous rating by same user
2449                    const totalScore = (currentAvg * currentCount) - oldScore + Number(score);
2450                    newCount = Math.max(1, currentCount);
2451                    newAvg = totalScore / newCount;
2452                } else {
2453                    // Brand new rating from this user
2454                    newCount = currentCount + 1;
2455                    newAvg = ((currentAvg * currentCount) + Number(score)) / newCount;
2456                }
2457
2458                newAvg = Math.max(1, Math.min(5, Math.round(newAvg * 10) / 10));
2459
2460                await setDoc(versionRef, {
2461                    starsAvg: newAvg,
2462                    ratingsCount: newCount
2463                }, { merge: true });
2464            } else {
2465                newCount = 2;
2466                newAvg = Math.max(1, Math.min(5, Math.round(((3.0 * 1 + Number(score)) / 2) * 10) / 10));
2467                await setDoc(versionRef, {
2468                    id: safeVersionId,
2469                    songId: this.currentSongId || '',
2470                    starsAvg: newAvg,
2471                    ratingsCount: newCount,
2472                    status: 'approved',
2473                    createdAt: serverTimestamp()
2474                }, { merge: true });
2475            }
2476
2477            // Update local version cache
2478            const cachedVer = this.versions.find(v => v.id === versionId || v.id === safeVersionId);
2479            if (cachedVer) {
2480                cachedVer.starsAvg = newAvg;
2481                cachedVer.ratingsCount = newCount;
2482            }
2483
2484            // Refresh UI
2485            this.updateStarsUI(newAvg, newCount, versionId);
2486
2487            if (window.showToast) {
2488                window.showToast(isEn ? `Rating updated: ${score}/5 stars.` : `¡Gracias! Has valorado esta versión con ${score} de 5 estrellas.`, 'success');
2489            }
2490        } catch (error) {
2491            console.error("Error al votar:", error);
2492            const errDetail = error?.message ? `: ${error.message}` : '';
2493            if (window.showToast) {
2494                window.showToast((isEn ? "Error submitting rating" : "Error al guardar tu valoración") + errDetail, 'error');
2495            }
2496        } finally {
2497            if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal();
2498        }
2499    }
2500}
2501
2502// Export a singleton instance
2503window.versionManager = new VersionManager();
2504
2505if (typeof window !== 'undefined') {
2506    if (document.readyState === 'loading') {
2507        document.addEventListener('DOMContentLoaded', () => {
2508            window.versionManager?.syncWithCurrentViewerSelection();
2509        });
2510    } else {
2511        window.versionManager?.syncWithCurrentViewerSelection();
2512    }
2513}
2514

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.