/** * Main Application Module * Handles app initialization, API calls, database operations, and library management */ // ========================================== // LOADER FUNCTIONS // ========================================== /** * Show loading overlay * @param {string} msg - Main message * @param {string} subtext - Subtext message */ function showLoader(msg, subtext = '') { document.getElementById('loadingText').textContent = msg; document.getElementById('loadingSubtext').textContent = subtext || 'Please wait...'; document.getElementById('loader').style.display = 'flex'; } /** * Hide loading overlay */ function hideLoader() { document.getElementById('loader').style.display = 'none'; } // ========================================== // UTILITY FUNCTIONS // ========================================== /** * Format bytes to human readable string * @param {number} bytes - Byte count * @returns {string} Formatted string */ function formatBytes(bytes) { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Format date to human readable string * @param {string} dateStr - Date string * @returns {string} Formatted date */ function formatDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // ========================================== // APP INITIALIZATION // ========================================== /** * Initialize app with data * @param {Object} data - Audio and transcription data */ function initApp(data) { document.getElementById('editorSection').classList.remove('d-none'); document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' }); transcriptionData = data.transcription; currentAudioData = data.audio_data; currentAudioFormat = data.audio_format; initWaveSurferFromBase64(data.audio_data, data.audio_format); initReader(data.text_content); refreshLibraryStats(); } // ========================================== // UPLOAD FORM HANDLER // ========================================== /** * Initialize upload form handler */ function initUploadForm() { document.getElementById('uploadForm').addEventListener('submit', async function(e) { e.preventDefault(); const audioInput = document.getElementById('audioFile').files[0]; const txtInput = document.getElementById('txtFile').files[0]; if (!audioInput || !txtInput) return; const fd = new FormData(); fd.append('audioFile', audioInput); fd.append('txtFile', txtInput); showLoader("Uploading...", "Processing audio and text files..."); try { const res = await fetch('/upload', { method: 'POST', body: fd }); const data = await res.json(); if (data.error) throw new Error(data.error); initApp(data); await refreshLibraryStats(); } catch (e) { alert(e.message); } finally { hideLoader(); } }); } // ========================================== // PROJECT/DATABASE FUNCTIONS // ========================================== /** * Get or create project by name * @returns {Promise} Project ID or null */ async function getOrCreateProject() { const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1'; try { const listRes = await fetch('/projects'); const listData = await listRes.json(); const existing = listData.projects.find(p => p.name === projectName); if (existing) { currentProjectId = existing.id; return existing.id; } const createRes = await fetch('/projects', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: projectName }) }); const createData = await createRes.json(); if (createData.error) { console.error('Error creating project:', createData.error); return null; } currentProjectId = createData.project_id; return createData.project_id; } catch (e) { console.error('Error getting/creating project:', e); return null; } } /** * Save all sections to database * @returns {Promise} Success status */ async function saveAllSectionsToDatabase() { const projectId = await getOrCreateProject(); if (!projectId) { console.error('Could not get project ID'); return false; } const sections = collectAllSectionsFromEditor(); console.log(`💾 Saving ${sections.length} sections to database...`); for (const sec of sections) { try { await fetch(`/projects/${projectId}/sections/save`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ chapter: sec.chapter, section: sec.section, text: sec.text || '', html_content: sec.htmlContent || '', tts_text: sec.ttsText || '', audio_data: sec.audioData || '', audio_format: sec.audioFormat || 'mp3', transcription: sec.transcription || [], voice: sec.voice || 'af_heart', image_data: sec.imageData || '', image_format: sec.imageFormat || 'png' }) }); console.log(` ✅ Saved Ch${sec.chapter}.Sec${sec.section}`); } catch (e) { console.error(` ❌ Error saving Ch${sec.chapter}.Sec${sec.section}:`, e); } } await refreshLibraryStats(); return true; } /** * Save single section to database * @param {number} chapterNum - Chapter number * @param {number} sectionNum - Section number * @param {Object} data - Section data * @returns {Promise} Success status */ async function saveSectionToDatabase(chapterNum, sectionNum, data) { const projectId = await getOrCreateProject(); if (!projectId) { console.error('Could not get project ID'); return false; } try { const res = await fetch(`/projects/${projectId}/sections/save`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ chapter: chapterNum, section: sectionNum, text: data.text || '', html_content: data.htmlContent || '', tts_text: data.ttsText || '', audio_data: data.audioData || '', audio_format: data.audioFormat || 'mp3', transcription: data.transcription || [], voice: data.voice || 'af_heart', image_data: data.imageData || '', image_format: data.imageFormat || 'png' }) }); const result = await res.json(); if (result.error) { console.error('Error saving section:', result.error); return false; } console.log(`✅ Saved Ch${chapterNum}.Sec${sectionNum} to database`); return true; } catch (e) { console.error('Error saving section to database:', e); return false; } } // ========================================== // AUDIO GENERATION FUNCTIONS // ========================================== /** * Generate audio for a marker (section or chapter) * @param {string} id - Marker ID */ async function generateMarkerAudio(id) { const markerEl = document.getElementById(`marker-${id}`); if (!markerEl) return; const type = markerEl.dataset.type; const num = markerEl.querySelector('input[type="number"]').value; const voice = markerEl.querySelector('select').value; const allMarkers = Array.from(document.querySelectorAll('.editor-marker')); const myIdx = allMarkers.indexOf(markerEl); // Save all sections first showLoader('Saving all sections...', 'Preserving your content before generation...'); await saveAllSectionsToDatabase(); // --- Generate for single section --- if (type === 'section') { const htmlContent = extractHtmlForMarker(markerEl, allMarkers); const displayText = extractMarkdownForMarker(markerEl, allMarkers); if (!displayText || displayText.trim().length < 2) { hideLoader(); alert("No text found."); return; } let genText = markerState[id]?.ttsText || extractPlainTextForMarker(markerEl, allMarkers); // Get image data from marker state const imageData = markerState[id]?.imageData || ''; const imageFormat = markerState[id]?.imageFormat || 'png'; // Find chapter number let chapterNum = 0; for (let i = myIdx - 1; i >= 0; i--) { if (allMarkers[i].classList.contains('chapter-marker')) { chapterNum = allMarkers[i].querySelector('input[type="number"]').value; break; } } const trackId = await processSingleSection(chapterNum, num, genText, displayText, htmlContent, voice, id, true, imageData, imageFormat); if (trackId) loadTrackFromPlaylist(trackId); } // --- Generate for entire chapter --- else if (type === 'chapter') { await generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id); } } /** * Generate audio for entire chapter * @param {Element} markerEl - Chapter marker element * @param {Element[]} allMarkers - All marker elements * @param {number} myIdx - Index of chapter marker * @param {number} num - Chapter number * @param {string} voice - Voice ID * @param {string} id - Marker ID */ async function generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id) { const sectionsToGenerate = []; // Check for implicit content after chapter marker let hasDirectContent = false; let nextEl = markerEl.nextSibling; while (nextEl) { if (nextEl.nodeType === 1 && nextEl.classList && nextEl.classList.contains('editor-marker')) { if (nextEl.classList.contains('section-marker')) break; else if (nextEl.classList.contains('chapter-marker')) break; } else if (nextEl.nodeType === 1 || (nextEl.nodeType === 3 && nextEl.textContent.trim())) { hasDirectContent = true; } nextEl = nextEl.nextSibling; } // Add implicit section if content exists without section markers if (hasDirectContent) { let nextMarkerIdx = myIdx + 1; let nextMarker = nextMarkerIdx < allMarkers.length ? allMarkers[nextMarkerIdx] : null; if (!nextMarker || nextMarker.classList.contains('chapter-marker')) { const secHtml = extractHtmlForMarker(markerEl, allMarkers); const secDisplay = extractMarkdownForMarker(markerEl, allMarkers); const secPlain = extractPlainTextForMarker(markerEl, allMarkers); if (secDisplay && secDisplay.trim().length > 1) { sectionsToGenerate.push({ id: id + '_implicit_1', num: 1, genText: secPlain, displayText: secDisplay, htmlContent: secHtml, voice: voice, imageData: '', imageFormat: 'png' }); } } } // Collect explicit section markers in this chapter for (let i = myIdx + 1; i < allMarkers.length; i++) { const m = allMarkers[i]; if (m.classList.contains('chapter-marker')) break; if (m.classList.contains('section-marker')) { const secId = m.dataset.markerId; const secNum = m.querySelector('input[type="number"]').value; const secVoice = m.querySelector('select').value; const secHtml = extractHtmlForMarker(m, allMarkers); const secDisplay = extractMarkdownForMarker(m, allMarkers); const secPlain = extractPlainTextForMarker(m, allMarkers); const secGen = markerState[secId]?.ttsText || secPlain; const imageData = markerState[secId]?.imageData || ''; const imageFormat = markerState[secId]?.imageFormat || 'png'; if (secDisplay && secDisplay.trim().length > 1) { sectionsToGenerate.push({ id: secId, num: secNum, genText: secGen, displayText: secDisplay, htmlContent: secHtml, voice: secVoice, imageData: imageData, imageFormat: imageFormat }); } } } if (sectionsToGenerate.length === 0) { hideLoader(); alert("No sections found in this chapter."); return; } console.log(`📚 Found ${sectionsToGenerate.length} sections in Chapter ${num}`); // Generate audio for each section showLoader(`Generating ${sectionsToGenerate.length} sections...`, 'This may take a while...'); let firstId = null; for (let i = 0; i < sectionsToGenerate.length; i++) { const sec = sectionsToGenerate[i]; document.getElementById('loadingText').textContent = `Generating section ${i + 1} of ${sectionsToGenerate.length}...`; document.getElementById('loadingSubtext').textContent = `Chapter ${num}, Section ${sec.num}`; console.log(`🔊 Generating Ch${num}.Sec${sec.num}...`); const trackId = await processSingleSection(num, sec.num, sec.genText, sec.displayText, sec.htmlContent, sec.voice, sec.id, false, sec.imageData, sec.imageFormat); if (trackId && i === 0) firstId = trackId; } hideLoader(); await refreshLibraryStats(); if (firstId) { loadTrackFromPlaylist(firstId); alert(`Chapter ${num} generation complete! Generated ${sectionsToGenerate.length} sections.`); } } /** * Process single section generation * @param {number} cNum - Chapter number * @param {number} sNum - Section number * @param {string} genText - Text for TTS generation * @param {string} displayText - Display text (markdown) * @param {string} htmlContent - HTML content * @param {string} voice - Voice ID * @param {string} markerId - Marker ID * @param {boolean} autoHide - Whether to auto-hide loader * @param {string} imageData - Base64 image data * @param {string} imageFormat - Image format (png, jpg, etc.) * @returns {Promise} Track ID or null */ async function processSingleSection(cNum, sNum, genText, displayText, htmlContent, voice, markerId, autoHide = true, imageData = '', imageFormat = 'png') { if (autoHide) showLoader(`Generating Section ${cNum}.${sNum}...`, 'Creating audio and timestamps...'); try { const res = await fetch('/generate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ text: genText, voice, save_to_db: false }) }); const data = await res.json(); if (data.error) throw new Error(data.error); const track = { id: markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9)), chapter: cNum, section: sNum, audioData: data.audio_data, audioFormat: data.audio_format, transcription: data.transcription, text: displayText, htmlContent: htmlContent, ttsText: genText, voice: voice, imageData: imageData, imageFormat: imageFormat }; // Update playlist const existingIdx = playlist.findIndex(t => t.chapter == cNum && t.section == sNum); if (existingIdx !== -1) { playlist[existingIdx] = track; } else { playlist.push(track); } // Sort playlist by chapter then section playlist.sort((a, b) => { if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter); return Number(a.section) - Number(b.section); }); updatePlaylistUI(); // Save to database await saveSectionToDatabase(cNum, sNum, track); if (autoHide) { hideLoader(); await refreshLibraryStats(); } return track.id; } catch (e) { console.error('Generation error:', e); if (autoHide) { hideLoader(); alert("Error: " + e.message); } return null; } } // ========================================== // LIBRARY MODAL FUNCTIONS // ========================================== let libraryModal = null; /** * Open library modal */ function openLibrary() { if (!libraryModal) libraryModal = new bootstrap.Modal(document.getElementById('libraryModal')); loadLibraryData(); libraryModal.show(); } /** * Refresh library statistics */ async function refreshLibraryStats() { try { const res = await fetch('/db/stats'); const stats = await res.json(); document.getElementById('statUploads').textContent = stats.uploads; document.getElementById('statGenerations').textContent = stats.generations; document.getElementById('statProjects').textContent = stats.projects; document.getElementById('statSections').textContent = stats.sections; document.getElementById('statDbSize').textContent = stats.database_size_mb + ' MB'; console.log('📊 Stats:', stats); } catch (e) { console.error('Stats error:', e); } } /** * Load all library data */ async function loadLibraryData() { await refreshLibraryStats(); await Promise.all([loadUploads(), loadGenerations(), loadProjects()]); } /** * Load uploads list */ async function loadUploads() { const container = document.getElementById('uploadsList'); try { const data = await (await fetch('/uploads')).json(); container.innerHTML = data.uploads.length === 0 ? '
No uploads yet
' : data.uploads.map(u => `
${u.filename}
${u.audio_format.toUpperCase()} • ${formatBytes(u.audio_size)} • ${formatDate(u.created_at)}
`).join(''); } catch (e) { container.innerHTML = '
Failed to load
'; } } /** * Load generations list */ async function loadGenerations() { const container = document.getElementById('generationsList'); try { const data = await (await fetch('/generations')).json(); container.innerHTML = data.generations.length === 0 ? '
No generations yet
' : data.generations.map(g => `
${g.name}
Voice: ${g.voice} • ${formatBytes(g.audio_size)} • ${formatDate(g.created_at)}
`).join(''); } catch (e) { container.innerHTML = '
Failed to load
'; } } /** * Load projects list */ async function loadProjects() { const container = document.getElementById('projectsList'); try { const data = await (await fetch('/projects')).json(); container.innerHTML = data.projects.length === 0 ? '
No projects yet
' : data.projects.map(p => `
${p.name}
${p.section_count} sections • ${formatDate(p.updated_at)}
`).join(''); } catch (e) { container.innerHTML = '
Failed to load
'; } } // ========================================== // LIBRARY ITEM LOADERS // ========================================== /** * Load upload by ID * @param {number} id - Upload ID */ async function loadUpload(id) { showLoader('Loading...', 'Retrieving upload data...'); try { const data = await (await fetch(`/uploads/${id}`)).json(); if (data.error) throw new Error(data.error); if (libraryModal) libraryModal.hide(); initApp(data); } catch (e) { alert(e.message); } finally { hideLoader(); } } /** * Load generation by ID * @param {number} id - Generation ID */ async function loadGeneration(id) { showLoader('Loading...', 'Retrieving generation data...'); try { const data = await (await fetch(`/generations/${id}`)).json(); if (data.error) throw new Error(data.error); if (libraryModal) libraryModal.hide(); initApp(data); } catch (e) { alert(e.message); } finally { hideLoader(); } } /** * Load project by ID * @param {number} id - Project ID */ async function loadProject(id) { showLoader('Loading project...', 'Retrieving all sections...'); try { const data = await (await fetch(`/projects/${id}`)).json(); if (data.error) throw new Error(data.error); if (libraryModal) libraryModal.hide(); document.getElementById('bulk-tab').click(); document.getElementById('bulkProjectName').value = data.name; currentProjectId = id; const editor = document.getElementById('bulk-editor'); editor.innerHTML = ''; Object.keys(markerState).forEach(key => delete markerState[key]); chapterCounter = 1; sectionCounter = 1; // Group sections by chapter const chapters = {}; data.sections.forEach(sec => { if (!chapters[sec.chapter]) chapters[sec.chapter] = []; chapters[sec.chapter].push(sec); }); // Rebuild editor const sortedChapterNums = Object.keys(chapters).sort((a, b) => Number(a) - Number(b)); sortedChapterNums.forEach(chapterNum => { const chapterSections = chapters[chapterNum]; const chapterVoice = chapterSections[0]?.voice || 'af_alloy'; const chapterMarkerId = `ch_${chapterNum}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; // Insert chapter marker const chapterMarkerHtml = createMarkerHTML('chapter', chapterNum, chapterVoice, chapterMarkerId); editor.insertAdjacentHTML('beforeend', chapterMarkerHtml); if (Number(chapterNum) >= chapterCounter) chapterCounter = Number(chapterNum) + 1; // Sort and insert sections chapterSections.sort((a, b) => a.section - b.section); chapterSections.forEach(sec => { const secMarkerId = `sec_${chapterNum}_${sec.section}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; const secMarkerHtml = createMarkerHTML('section', sec.section, sec.voice, secMarkerId); editor.insertAdjacentHTML('beforeend', secMarkerHtml); const marker = document.getElementById(`marker-${secMarkerId}`); if (marker) { let content = sec.html_content; if (!content || content.trim() === '') { content = sec.text_content ? `

${sec.text_content}

` : '


'; } // Remove placeholder paragraph let nextEl = marker.nextElementSibling; if (nextEl && nextEl.tagName === 'P' && (nextEl.innerHTML === '
' || nextEl.innerHTML.trim() === '')) { nextEl.remove(); } // Insert content after marker const tempContainer = document.createElement('div'); tempContainer.innerHTML = content; const insertBeforeElement = marker.nextSibling; while (tempContainer.firstChild) { editor.insertBefore(tempContainer.firstChild, insertBeforeElement); } // Ensure at least one paragraph exists after content if (!marker.nextSibling || (marker.nextSibling.classList && marker.nextSibling.classList.contains('editor-marker'))) { const emptyP = document.createElement('p'); emptyP.innerHTML = '
'; if (insertBeforeElement) { editor.insertBefore(emptyP, insertBeforeElement); } else { editor.appendChild(emptyP); } } } // Restore TTS text to marker state if (sec.tts_text) { updateMarkerData(secMarkerId, 'ttsText', sec.tts_text); } // Restore image data to marker state and show preview if (sec.image_data && sec.image_data.length > 0) { updateMarkerData(secMarkerId, 'imageData', sec.image_data); updateMarkerData(secMarkerId, 'imageFormat', sec.image_format || 'png'); // Show image preview after DOM is ready setTimeout(() => { const imgFormat = sec.image_format || 'png'; const dataUrl = `data:image/${imgFormat};base64,${sec.image_data}`; showImagePreview(secMarkerId, dataUrl, 'Loaded image', 0, imgFormat); }, 100); } if (Number(sec.section) >= sectionCounter) sectionCounter = Number(sec.section) + 1; }); }); // Initialize image handlers for newly created markers setTimeout(() => { initializeImageHandlers(); }, 300); // Build playlist from sections with audio playlist = data.sections .filter(s => s.audio_data && s.audio_data.length > 0) .map(sec => ({ id: `sec_${sec.chapter}_${sec.section}_loaded`, chapter: sec.chapter, section: sec.section, audioData: sec.audio_data, audioFormat: sec.audio_format || 'mp3', transcription: sec.transcription || [], text: sec.text_content, htmlContent: sec.html_content, ttsText: sec.tts_text, voice: sec.voice, imageData: sec.image_data || '', imageFormat: sec.image_format || 'png' })) .sort((a, b) => { if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter); return Number(a.section) - Number(b.section); }); updatePlaylistUI(); document.getElementById('editorSection').classList.remove('d-none'); // Load first track if available if (playlist.length > 0) { loadTrackFromPlaylist(playlist[0].id); } console.log(`✅ Loaded project: ${data.name} with ${data.sections.length} sections, ${playlist.length} with audio`); } catch (e) { alert(e.message); console.error('Load project error:', e); } finally { hideLoader(); } } // ========================================== // DOWNLOAD FUNCTIONS // ========================================== /** * Download upload by ID * @param {number} id - Upload ID */ function downloadUpload(id) { window.location.href = `/uploads/${id}/download`; } /** * Download generation by ID * @param {number} id - Generation ID */ function downloadGeneration(id) { window.location.href = `/generations/${id}/download`; } /** * Download project by ID * @param {number} id - Project ID */ function downloadProject(id) { window.location.href = `/projects/${id}/download`; } // ========================================== // DELETE FUNCTIONS // ========================================== /** * Delete upload by ID * @param {number} id - Upload ID */ async function deleteUpload(id) { if (!confirm('Delete this upload?')) return; showLoader('Deleting...', 'Removing upload from database...'); try { await fetch(`/uploads/${id}`, { method: 'DELETE' }); await loadLibraryData(); } catch (e) { alert(e.message); } finally { hideLoader(); } } /** * Delete generation by ID * @param {number} id - Generation ID */ async function deleteGeneration(id) { if (!confirm('Delete this generation?')) return; showLoader('Deleting...', 'Removing generation from database...'); try { await fetch(`/generations/${id}`, { method: 'DELETE' }); await loadLibraryData(); } catch (e) { alert(e.message); } finally { hideLoader(); } } /** * Delete project by ID * @param {number} id - Project ID */ async function deleteProject(id) { if (!confirm('Delete this project and all its sections?')) return; showLoader('Deleting...', 'Removing project from database...'); try { await fetch(`/projects/${id}`, { method: 'DELETE' }); await loadLibraryData(); } catch (e) { alert(e.message); } finally { hideLoader(); } } // ========================================== // INITIALIZATION // ========================================== // Initialize on DOM ready document.addEventListener('DOMContentLoaded', function() { initUploadForm(); refreshLibraryStats(); });