diff --git a/db.py b/db.py index 43ef179..1efb576 100644 --- a/db.py +++ b/db.py @@ -135,6 +135,8 @@ def init_db(): ('projects', 'thumbnail_path', 'TEXT'), ('markdown_blocks', 'audio_path', 'TEXT'), ('block_images', 'image_path', 'TEXT'), + # --- v4.4: thumbnail auto-generated flag (1=auto, 0=user-uploaded) --- + ('projects', 'thumbnail_auto', 'INTEGER DEFAULT 0'), ] for table, column, definition in migrations: diff --git a/media-storage/project_1/thumbnail.jpeg b/media-storage/project_1/thumbnail.jpeg new file mode 100644 index 0000000..a1d8e6e Binary files /dev/null and b/media-storage/project_1/thumbnail.jpeg differ diff --git a/media-storage/project_3/thumbnail.jpeg b/media-storage/project_3/thumbnail.jpeg new file mode 100644 index 0000000..1c8352f Binary files /dev/null and b/media-storage/project_3/thumbnail.jpeg differ diff --git a/media_storage.py b/media_storage.py index 0ba50fe..a95173e 100644 --- a/media_storage.py +++ b/media_storage.py @@ -140,6 +140,57 @@ def get_storage_usage_bytes(): return total +# ============================================ +# Pending thumbnails (v4.4) — আপলোডের সময় জেনারেট, সেভের সময় commit +# ============================================ + +import uuid as _uuid + +def _pending_thumbs_dir(): + return os.path.join(MEDIA_STORAGE_DIR, '_pending_thumbs') + + +def save_pending_thumbnail(image_bytes, image_format='jpeg'): + """ + অস্থায়ী থাম্বনেইল সেভ করে একটা token রিটার্ন করে। + প্রজেক্ট এখনো তৈরি হয়নি বলে project_id ছাড়াই রাখা হয়। + """ + if not image_bytes: + return None + d = _pending_thumbs_dir() + _ensure_dir(d) + fmt = (image_format or 'jpeg').lower() + token = _uuid.uuid4().hex + filename = f'{token}.{fmt}' + with open(os.path.join(d, filename), 'wb') as f: + f.write(image_bytes) + return filename # token = filename + + +def read_pending_thumbnail(token): + """Pending thumbnail-এর (bytes, format) ফেরত দেয়। না থাকলে (None, None)।""" + if not token or '/' in token or '\\' in token or '..' in token: + return None, None + path = os.path.join(_pending_thumbs_dir(), token) + if not os.path.exists(path): + return None, None + fmt = token.rsplit('.', 1)[-1] if '.' in token else 'jpeg' + with open(path, 'rb') as f: + return f.read(), fmt + + +def delete_pending_thumbnail(token): + """Pending thumbnail মুছে দেয় (commit বা বাতিলের পর)।""" + if not token or '/' in token or '\\' in token or '..' in token: + return + path = os.path.join(_pending_thumbs_dir(), token) + if os.path.exists(path): + try: + os.remove(path) + except OSError: + pass + + # ============================================ # Delete operations # ============================================ diff --git a/requirements.txt b/requirements.txt index a021a60..56981e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,9 @@ requests==2.32.3 # --- Audio processing --- pydub==0.25.1 +# --- Image processing (thumbnails) --- +Pillow==10.4.0 + # --- Document processing --- PyMuPDF==1.24.10 python-docx==1.1.2 diff --git a/routes/docx_routes.py b/routes/docx_routes.py index 6158012..3cde2da 100644 --- a/routes/docx_routes.py +++ b/routes/docx_routes.py @@ -6,6 +6,8 @@ from flask import Blueprint, request, jsonify from db import get_db from docx_processor import process_docx_to_markdown from ai_processor import process_document_smartly +from thumbnail_generator import generate_docx_thumbnail +from media_storage import save_pending_thumbnail from auth import login_required docx_bp = Blueprint('docx', __name__) @@ -44,11 +46,27 @@ def upload_docx(): print(f"✅ Word document processed & reconstructed: {block_count} blocks ({text_count} text, {image_count} images)") + # --- v4.4: DOCX থেকে অটো থাম্বনেইল (embedded thumbnail / প্রথম ইমেজ) --- + pending_thumbnail = None + pending_thumbnail_format = None + try: + thumb_bytes, thumb_fmt = generate_docx_thumbnail(file_bytes, smart_blocks) + if thumb_bytes: + token = save_pending_thumbnail(thumb_bytes, thumb_fmt) + if token: + pending_thumbnail = token + pending_thumbnail_format = thumb_fmt + print(f" 🖼️ Auto-thumbnail generated: {token} ({len(thumb_bytes)} bytes)") + except Exception as te: + print(f" ⚠️ Thumbnail step skipped: {te}") + return jsonify({ 'success': True, 'filename': doc_file.filename, 'metadata': result.get('metadata', {}), - 'blocks': smart_blocks + 'blocks': smart_blocks, + 'pending_thumbnail': pending_thumbnail, + 'pending_thumbnail_format': pending_thumbnail_format }) except Exception as e: diff --git a/routes/pdf_routes.py b/routes/pdf_routes.py index 463c787..2486642 100644 --- a/routes/pdf_routes.py +++ b/routes/pdf_routes.py @@ -6,6 +6,8 @@ from flask import Blueprint, request, jsonify from db import get_db from pdf_processor import process_pdf_to_markdown from ai_processor import process_document_smartly +from thumbnail_generator import generate_pdf_thumbnail +from media_storage import save_pending_thumbnail from auth import login_required pdf_bp = Blueprint('pdf', __name__) @@ -35,6 +37,20 @@ def upload_pdf(): # --- AI Powered Smart Reconstruction & Section Tagging --- smart_blocks = process_document_smartly(result['markdown_blocks'], result['metadata']) + # --- v4.4: প্রথম পেজ থেকে অটো থাম্বনেইল জেনারেট --- + pending_thumbnail = None + pending_thumbnail_format = None + try: + thumb_bytes, thumb_fmt = generate_pdf_thumbnail(pdf_bytes) + if thumb_bytes: + token = save_pending_thumbnail(thumb_bytes, thumb_fmt) + if token: + pending_thumbnail = token + pending_thumbnail_format = thumb_fmt + print(f" 🖼️ Auto-thumbnail generated: {token} ({len(thumb_bytes)} bytes)") + except Exception as te: + print(f" ⚠️ Thumbnail step skipped: {te}") + # Save PDF document record db = get_db() cursor = db.cursor() @@ -59,7 +75,9 @@ def upload_pdf(): 'filename': pdf_file.filename, 'page_count': result['page_count'], 'metadata': result['metadata'], - 'blocks': smart_blocks + 'blocks': smart_blocks, + 'pending_thumbnail': pending_thumbnail, + 'pending_thumbnail_format': pending_thumbnail_format }) except Exception as e: diff --git a/routes/project_routes.py b/routes/project_routes.py index d5e38d0..f3af20b 100644 --- a/routes/project_routes.py +++ b/routes/project_routes.py @@ -11,8 +11,10 @@ from auth import login_required from media_storage import ( save_audio, save_image, save_thumbnail, read_file_base64, get_safe_abs_path, - delete_project_media, get_storage_usage_bytes + delete_project_media, get_storage_usage_bytes, + read_pending_thumbnail, delete_pending_thumbnail # v4.4 ) +from thumbnail_generator import generate_text_cover # v4.4 backfill project_bp = Blueprint('project', __name__) @@ -259,7 +261,7 @@ def get_block_audio(project_id, block_id): @project_bp.route('/api/projects/', methods=['PUT']) @login_required def update_project(project_id): - """Update project name.""" + """Update project name and metadata (author/description/category).""" data = request.json name = data.get('name', '').strip() @@ -269,10 +271,28 @@ def update_project(project_id): db = get_db() cursor = db.cursor() + # মেটাডেটা ফিল্ড — পাঠানো হলেই আপডেট হবে + updates = ['name = ?'] + params = [name] + + if 'author' in data: + updates.append('author = ?') + params.append((data.get('author') or '').strip()) + if 'description' in data: + updates.append('description = ?') + params.append((data.get('description') or '').strip()) + if 'category' in data: + updates.append('category = ?') + params.append((data.get('category') or '').strip()) + + updates.append('updated_at = CURRENT_TIMESTAMP') + params.append(project_id) + try: - cursor.execute(''' - UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (name, project_id)) + cursor.execute( + f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", + params + ) db.commit() if cursor.rowcount == 0: @@ -422,6 +442,32 @@ def save_project_content(project_id): (img_rel, image_id) ) + # --- v4.4: pending auto-thumbnail commit করা (শুধু যদি প্রজেক্টে থাম্বনেইল না থাকে) --- + pending_token = clean_str(data.get('pending_thumbnail', '')) + if pending_token: + cursor.execute( + 'SELECT thumbnail_path, thumbnail_data FROM projects WHERE id = ?', + (project_id,) + ) + prow = cursor.fetchone() + already_has_thumb = prow and (prow['thumbnail_path'] or prow['thumbnail_data']) + + if not already_has_thumb: + thumb_bytes, thumb_fmt = read_pending_thumbnail(pending_token) + if thumb_bytes: + rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt) + if rel_path: + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, project_id)) + print(f" 🖼️ Auto-thumbnail applied to project {project_id}") + + # commit বা বাতিল — যেভাবেই হোক pending ফাইল মুছে ফেলি + delete_pending_thumbnail(pending_token) + cursor.execute(''' UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (project_id,)) @@ -531,7 +577,8 @@ def upload_thumbnail(project_id): rel_path = save_thumbnail(project_id, img_bytes, fmt) cursor.execute(''' - UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ? + UPDATE projects SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 0 WHERE id = ? ''', (rel_path, fmt, project_id)) db.commit() @@ -565,12 +612,230 @@ def delete_thumbnail(project_id): # v4.3: Database Maintenance (VACUUM + stats) # ============================================ +@project_bp.route('/api/projects//generate-thumbnail', methods=['POST']) +@login_required +def generate_single_thumbnail(project_id): + """ + একটি নির্দিষ্ট প্রজেক্টের থাম্বনেইল auto-generate/regenerate করে (v4.4)। + সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover। + """ + import base64 + from thumbnail_generator import _optimize_image_bytes + + db = get_db() + cursor = db.cursor() + + cursor.execute('SELECT id, name, author FROM projects WHERE id = ?', (project_id,)) + proj = cursor.fetchone() + if not proj: + return jsonify({'error': 'Project not found'}), 404 + + thumb_bytes = None + thumb_fmt = None + source = None + + # সোর্স ১: প্রথম embedded image + cursor.execute(''' + SELECT bi.image_data, bi.image_path, bi.image_format + FROM block_images bi + JOIN markdown_blocks mb ON bi.block_id = mb.id + JOIN chapters c ON mb.chapter_id = c.id + WHERE c.project_id = ? + AND ((bi.image_path IS NOT NULL AND bi.image_path != '') + OR (bi.image_data IS NOT NULL AND bi.image_data != '')) + ORDER BY c.chapter_number, mb.block_order, bi.id + LIMIT 1 + ''', (project_id,)) + img_row = cursor.fetchone() + + if img_row: + raw = None + if img_row['image_path']: + b64 = read_file_base64(img_row['image_path']) + if b64: + try: + raw = base64.b64decode(b64) + except Exception: + raw = None + elif img_row['image_data']: + try: + raw = base64.b64decode(clean_str(img_row['image_data'])) + except Exception: + raw = None + + if raw and len(raw) > 4000: + thumb_bytes, thumb_fmt = _optimize_image_bytes( + raw, img_row['image_format'] or 'png' + ) + if thumb_bytes: + source = 'image' + + # সোর্স ২: text-cover fallback + if not thumb_bytes: + thumb_bytes, thumb_fmt = generate_text_cover(proj['name'], proj['author'] or '') + if thumb_bytes: + source = 'text' + + if not thumb_bytes: + return jsonify({'error': 'Could not generate thumbnail (Pillow may be missing)'}), 500 + + rel_path = save_thumbnail(project_id, thumb_bytes, thumb_fmt) + if not rel_path: + return jsonify({'error': 'Failed to save thumbnail'}), 500 + + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, project_id)) + db.commit() + + b64 = read_file_base64(rel_path) + return jsonify({ + 'success': True, + 'source': source, + 'thumbnail_data': b64, + 'thumbnail_format': thumb_fmt, + 'message': f'Thumbnail generated from {"first image" if source == "image" else "text cover"}' + }) + + +@project_bp.route('/api/maintenance/backfill-thumbnails', methods=['POST']) +@login_required +def backfill_thumbnails(): + """ + থাম্বনেইল জেনারেট করে (v4.4)। + সোর্স: (১) প্রথম embedded image block, (২) fallback হিসেবে text-cover। + + force=False → শুধু থাম্বনেইল-বিহীন প্রজেক্ট। + force=True → auto-generated থাম্বনেইলও নতুন করে বানায় (user-uploaded রক্ষা পায়)। + """ + data = request.json or {} + force = bool(data.get('force', False)) + + db = get_db() + cursor = db.cursor() + + if force: + # user-uploaded (thumbnail_auto=0 AND থাম্বনেইল আছে) ছাড়া সব + cursor.execute(''' + SELECT id, name, author FROM projects + WHERE (thumbnail_path IS NULL OR thumbnail_path = '') + OR thumbnail_auto = 1 + ''') + else: + cursor.execute(''' + SELECT id, name, author FROM projects + WHERE (thumbnail_path IS NULL OR thumbnail_path = '') + AND (thumbnail_data IS NULL OR thumbnail_data = '') + ''') + projects = cursor.fetchall() + + generated = 0 + from_image = 0 + from_text = 0 + failed = 0 + + for proj in projects: + pid = proj['id'] + thumb_bytes = None + thumb_fmt = None + + # সোর্স ১: প্রথম embedded image (path বা base64) + cursor.execute(''' + SELECT bi.image_data, bi.image_path, bi.image_format + FROM block_images bi + JOIN markdown_blocks mb ON bi.block_id = mb.id + JOIN chapters c ON mb.chapter_id = c.id + WHERE c.project_id = ? + AND ((bi.image_path IS NOT NULL AND bi.image_path != '') + OR (bi.image_data IS NOT NULL AND bi.image_data != '')) + ORDER BY c.chapter_number, mb.block_order, bi.id + LIMIT 1 + ''', (pid,)) + img_row = cursor.fetchone() + + if img_row: + import base64 + raw = None + if img_row['image_path']: + raw = read_file_base64(img_row['image_path']) + if raw: + try: + raw = base64.b64decode(raw) + except Exception: + raw = None + elif img_row['image_data']: + try: + raw = base64.b64decode(clean_str(img_row['image_data'])) + except Exception: + raw = None + + if raw and len(raw) > 4000: + from thumbnail_generator import _optimize_image_bytes + thumb_bytes, thumb_fmt = _optimize_image_bytes( + raw, img_row['image_format'] or 'png' + ) + if thumb_bytes: + from_image += 1 + + # সোর্স ২: text-cover fallback + if not thumb_bytes: + thumb_bytes, thumb_fmt = generate_text_cover( + proj['name'], proj['author'] or '' + ) + if thumb_bytes: + from_text += 1 + + if not thumb_bytes: + failed += 1 + continue + + rel_path = save_thumbnail(pid, thumb_bytes, thumb_fmt) + if rel_path: + cursor.execute(''' + UPDATE projects + SET thumbnail_path = ?, thumbnail_data = NULL, thumbnail_format = ?, + thumbnail_auto = 1 + WHERE id = ? + ''', (rel_path, thumb_fmt, pid)) + generated += 1 + else: + failed += 1 + + db.commit() + + return jsonify({ + 'success': True, + 'total_without_thumbnail': len(projects), + 'generated': generated, + 'from_image': from_image, + 'from_text': from_text, + 'failed': failed, + 'force': force, + 'message': f'{generated} thumbnails generated ' + f'({from_image} from images, {from_text} text covers).' + }) + + @project_bp.route('/api/maintenance/db-stats', methods=['GET']) @login_required def db_stats(): """ডেটাবেস সাইজ, ফাঁকা স্পেস (%), এবং মিডিয়া স্টোরেজ সাইজ রিটার্ন করে।""" - stats = get_db_stats() - media_bytes = get_storage_usage_bytes() + try: + stats = get_db_stats() + except Exception as e: + print(f"⚠️ get_db_stats failed: {e}") + return jsonify({'error': f'Database stats failed: {str(e)}'}), 500 + + # মিডিয়া স্ক্যান আলাদা try/except — ফোল্ডার বিশাল/দুর্গম হলেও stats রিটার্ন হবে + try: + media_bytes = get_storage_usage_bytes() + except Exception as e: + print(f"⚠️ get_storage_usage_bytes failed: {e}") + media_bytes = 0 + stats['media_size_bytes'] = media_bytes stats['media_size_mb'] = round(media_bytes / (1024 * 1024), 2) return jsonify(stats) diff --git a/static/css/style.css b/static/css/style.css index 4a225ee..03e0826 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1331,7 +1331,7 @@ body { .project-thumb-overlay { position: absolute; inset: 0; - background: rgba(0,0,0,0.6); + background: rgba(0,0,0,0.62); color: white; display: flex; flex-direction: column; @@ -1340,15 +1340,38 @@ body { opacity: 0; transition: opacity 0.2s; font-size: 0.7rem; - gap: 4px; + gap: 6px; + padding: 6px; } .project-thumb:hover .project-thumb-overlay { opacity: 1; } -.project-thumb-overlay i { - font-size: 1.2rem; +.thumb-action-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + width: 100%; + background: rgba(255,255,255,0.14); + border: 1px solid rgba(255,255,255,0.3); + color: white; + border-radius: 6px; + padding: 5px 4px; + font-size: 0.66rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.thumb-action-btn:hover { + background: rgba(255,255,255,0.28); +} + +.thumb-action-btn i { + font-size: 1rem; } .project-info-v2 { @@ -1375,6 +1398,57 @@ body { font-style: italic; } +.project-public-link { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; + font-size: 0.78rem; + color: var(--text-muted); + max-width: 100%; +} + +.project-public-link > i { + color: var(--success-color); + flex-shrink: 0; +} + +.project-public-link a { + color: var(--primary-color); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} + +.project-public-link a:hover { + text-decoration: underline; +} + +.link-copy-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 5px; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + font-size: 0.72rem; + transition: all 0.15s; + padding: 0; +} + +.link-copy-btn:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + .project-actions-v2 { display: flex; gap: 6px; diff --git a/static/js/app.js b/static/js/app.js index 5b13a55..aa473e7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -21,6 +21,7 @@ let dbMaintenanceModal = null; let publishingProjectId = null; let currentWorkflowStage = 'upload'; let allArchiveProjects = []; +let pendingThumbnailToken = null; // v4.4: auto-generated thumbnail token // ============================================ // Initialization @@ -540,7 +541,10 @@ async function saveProject() { const saveResponse = await fetch(`/api/projects/${projectId}/save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chapters }) + body: JSON.stringify({ + chapters, + pending_thumbnail: pendingThumbnailToken || '' + }) }); const saveData = await saveResponse.json(); @@ -549,6 +553,9 @@ async function saveProject() { throw new Error(saveData.error); } + // v4.4: থাম্বনেইল একবার commit হলে আর দরকার নেই + pendingThumbnailToken = null; + hideLoader(); showNotification('Project saved successfully!', 'success'); @@ -608,14 +615,32 @@ async function openProjectArchive() { const canPublish = project.audio_count > 0; + const bookUrl = `${window.location.origin}/read/${project.id}`; + const publishedLinkHtml = project.is_published + ? `` + : ''; + return `
-
+
${thumbHtml}
- - Edit + +
@@ -632,11 +657,7 @@ async function openProjectArchive() { ${project.view_count} views
${project.author ? `
${escapeHtml(project.author)}
` : ''} -
- -
@@ -650,7 +671,7 @@ async function openProjectArchive() { Publish ` } -
-
`; }).join(''); @@ -684,85 +697,135 @@ async function openProjectArchive() { } // ============================================ -// Rename +// Edit Project Details (Name + Author + Description + Category) // ============================================ -function startEditProjectName(projectId) { - document.getElementById(`project-info-${projectId}`).style.display = 'none'; - document.getElementById(`project-actions-${projectId}`).style.display = 'none'; - - document.getElementById(`project-edit-${projectId}`).style.display = 'block'; - document.getElementById(`project-edit-actions-${projectId}`).style.display = 'flex'; - - const input = document.getElementById(`edit-input-${projectId}`); - input.focus(); - input.select(); - - input.onkeydown = function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - saveProjectName(projectId); - } else if (e.key === 'Escape') { - cancelEditProjectName(projectId); - } - }; -} +let editProjectModal = null; +let editingProjectId = null; -function cancelEditProjectName(projectId) { - document.getElementById(`project-info-${projectId}`).style.display = 'block'; - document.getElementById(`project-actions-${projectId}`).style.display = 'flex'; - - document.getElementById(`project-edit-${projectId}`).style.display = 'none'; - document.getElementById(`project-edit-actions-${projectId}`).style.display = 'none'; - - const textElement = document.getElementById(`project-name-text-${projectId}`); - const input = document.getElementById(`edit-input-${projectId}`); - if (textElement && input) { - input.value = textElement.textContent; +function copyArchiveLink(url, btnEl) { + const done = () => { + if (btnEl) { + const icon = btnEl.querySelector('i'); + if (icon) { + icon.classList.remove('bi-clipboard'); + icon.classList.add('bi-check-lg'); + setTimeout(() => { + icon.classList.remove('bi-check-lg'); + icon.classList.add('bi-clipboard'); + }, 1500); + } + } + showNotification('Link copied', 'success'); + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done)); + } else { + fallbackCopy(url, done); } } -async function saveProjectName(projectId) { - const input = document.getElementById(`edit-input-${projectId}`); - const newName = input.value.trim(); - - if (!newName) { +function fallbackCopy(text, cb) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + if (cb) cb(); +} + +function openEditProject(projectId) { + editingProjectId = projectId; + const project = allArchiveProjects.find(p => p.id === projectId); + if (!project) return; + + if (!editProjectModal) { + const modalHtml = ` + `; + document.body.insertAdjacentHTML('beforeend', modalHtml); + editProjectModal = new bootstrap.Modal(document.getElementById('editProjectModal')); + } + + document.getElementById('edit-name').value = project.name || ''; + document.getElementById('edit-author').value = project.author || ''; + document.getElementById('edit-description').value = project.description || ''; + document.getElementById('edit-category').value = project.category || ''; + + editProjectModal.show(); +} + +async function saveEditProject() { + const name = document.getElementById('edit-name').value.trim(); + const author = document.getElementById('edit-author').value.trim(); + const description = document.getElementById('edit-description').value.trim(); + const category = document.getElementById('edit-category').value.trim(); + + if (!name) { showNotification('Project name cannot be empty', 'warning'); return; } - + try { - const response = await fetch(`/api/projects/${projectId}`, { + const response = await fetch(`/api/projects/${editingProjectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName }) + body: JSON.stringify({ name, author, description, category }) }); - const data = await response.json(); - + if (data.error) { showNotification(data.error, 'error'); return; } - - const textEl = document.getElementById(`project-name-text-${projectId}`); - if (textEl) textEl.textContent = newName; - - const cached = allArchiveProjects.find(p => p.id === projectId); - if (cached) cached.name = newName; - - cancelEditProjectName(projectId); - showNotification('Project renamed successfully', 'success'); - - if (currentProject.id === projectId) { - currentProject.name = newName; + + if (currentProject.id === editingProjectId) { + currentProject.name = name; const nameInput = document.getElementById('projectName'); - if (nameInput) nameInput.value = newName; + if (nameInput) nameInput.value = name; } - + + editProjectModal.hide(); + showNotification('Project updated successfully', 'success'); + openProjectArchive(); } catch (error) { console.error(error); - showNotification('Failed to rename project', 'error'); + showNotification('Failed to update project', 'error'); } } @@ -1053,7 +1116,7 @@ async function loadAudioBlocksInBackground(projectId, blockIds) { async function deleteProject(projectId) { - if (!confirm('Are you sure you want to delete this project? This action cannot be undone.\n\nএই প্রজেক্টের অডিও ও ইমেজ ফাইলগুলোও মুছে যাবে।')) return; + if (!confirm('Are you sure you want to delete this project? This action cannot be undone.\n\nThe audio and image files for this project will also be deleted.')) return; showLoader('Deleting...'); @@ -1086,16 +1149,31 @@ function openDbMaintenance() { async function loadDbStats() { const loadingEl = document.getElementById('dbStatsLoading'); const contentEl = document.getElementById('dbStatsContent'); - if (loadingEl) loadingEl.style.display = 'block'; + if (loadingEl) { + loadingEl.style.display = 'block'; + loadingEl.innerHTML = ` +
+

Loading storage info...

+ `; + } if (contentEl) contentEl.style.display = 'none'; + // ২০ সেকেন্ডের timeout — সার্ভার আটকে থাকলেও UI মুক্ত হবে + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + try { - const resp = await fetch('/api/maintenance/db-stats'); + const resp = await fetch('/api/maintenance/db-stats', { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!resp.ok) { + throw new Error(`Server returned ${resp.status}`); + } + const s = await resp.json(); if (s.error) { - showNotification(s.error, 'error'); - return; + throw new Error(s.error); } document.getElementById('dbmFileSize').textContent = `${s.file_size_mb} MB`; @@ -1123,29 +1201,78 @@ async function loadDbStats() { advice.className = 'alert alert-warning'; advice.style.display = 'block'; advice.innerHTML = `` + - `ডেটাবেসে ${s.free_percent}% ফাঁকা স্পেস জমেছে। ` + - `Run VACUUM চালিয়ে এটি reclaim করতে পারেন।`; + `The database has ${s.free_percent}% free space accumulated. ` + + `You can run VACUUM to reclaim it.`; } else { advice.className = 'alert alert-success'; advice.style.display = 'block'; advice.innerHTML = `` + - `ফাঁকা স্পেস কম (${s.free_percent}%) — এখন VACUUM চালানোর দরকার নেই।`; + `Free space is low (${s.free_percent}%) — no need to run VACUUM right now.`; } if (loadingEl) loadingEl.style.display = 'none'; if (contentEl) contentEl.style.display = 'block'; } catch (e) { + clearTimeout(timeoutId); console.error(e); + + const msg = e.name === 'AbortError' + ? 'Loading storage info is taking too long (timeout). This can happen if the database is large.' + : `Failed to load storage info: ${e.message}`; + + if (loadingEl) { + loadingEl.style.display = 'block'; + loadingEl.innerHTML = ` +
+ +

${msg}

+ +
+ `; + } + if (contentEl) contentEl.style.display = 'none'; + showNotification('Failed to load storage info', 'error'); } } +async function autoGenerateThumbnail(projectId, btnEl) { + const overlay = btnEl ? btnEl.closest('.project-thumb-overlay') : null; + if (overlay) overlay.style.pointerEvents = 'none'; + + showLoader('Generating thumbnail...', 'From document content'); + + try { + const resp = await fetch(`/api/projects/${projectId}/generate-thumbnail`, { + method: 'POST' + }); + const data = await resp.json(); + + hideLoader(); + + if (data.error) { + showNotification(data.error, 'error'); + return; + } + + showNotification(data.message || 'Thumbnail generated', 'success'); + openProjectArchive(); + } catch (e) { + hideLoader(); + showNotification('Failed to generate thumbnail', 'error'); + } finally { + if (overlay) overlay.style.pointerEvents = ''; + } +} + async function runDbVacuum() { const vacuumBtn = document.getElementById('dbmVacuumBtn'); const refreshBtn = document.getElementById('dbmRefreshBtn'); - if (!confirm('VACUUM এখন চালাবেন? এটি ডেটাবেস ছোট করবে কিন্তু কিছু সময় (ডেটাবেস বড় হলে কয়েক মিনিট) নিতে পারে।')) { + if (!confirm('Run VACUUM now? It will shrink the database but may take some time (a few minutes if the database is large).')) { return; } diff --git a/static/js/pdf-handler.js b/static/js/pdf-handler.js index fffd944..45382e1 100644 --- a/static/js/pdf-handler.js +++ b/static/js/pdf-handler.js @@ -92,6 +92,9 @@ async function handlePdfFile(file) { document.getElementById('projectName').value = projectName; currentProject.name = projectName; + // v4.4: auto-generated thumbnail token সংরক্ষণ + pendingThumbnailToken = data.pending_thumbnail || null; + renderDocumentBlocks(data.blocks); document.getElementById('uploadSection').style.display = 'none'; @@ -145,6 +148,9 @@ async function handleWordFile(file) { document.getElementById('projectName').value = projectName; currentProject.name = projectName; + // v4.4: auto-generated thumbnail token সংরক্ষণ + pendingThumbnailToken = data.pending_thumbnail || null; + renderDocumentBlocks(data.blocks); document.getElementById('uploadSection').style.display = 'none'; diff --git a/templates/index.html b/templates/index.html index 6a0c36d..9dad238 100644 --- a/templates/index.html +++ b/templates/index.html @@ -179,6 +179,9 @@ + + Library + + `; + + for (const c of sortedCats) { + const isActive = activeCategory.toLowerCase() === c.label.toLowerCase(); + html += ` + + `; + } + + if (othersCount > 0) { + html += ` + + `; + } + + html += ``; + return html; + } + + function selectCategory(cat) { + activeCategory = cat; + applyFilters(); // applyFilters → renderBookcase → nav নতুন করে বসবে + } + + function filterBooks(query) { + currentSearch = (query || '').toLowerCase().trim(); + applyFilters(); + } + + function applyFilters() { + let filtered = allBooks; + + // category ফিল্টার + if (activeCategory === OTHERS_KEY) { + filtered = filtered.filter(b => !(b.category || '').trim()); + } else if (activeCategory !== 'all') { + filtered = filtered.filter(b => + (b.category || '').trim().toLowerCase() === activeCategory.toLowerCase() + ); + } + + // সার্চ ফিল্টার + if (currentSearch) { + filtered = filtered.filter(b => + b.name.toLowerCase().includes(currentSearch) || + (b.author && b.author.toLowerCase().includes(currentSearch)) || + (b.description && b.description.toLowerCase().includes(currentSearch)) + ); + } + renderBookcase(filtered); } + + function escapeAttr(text) { + return (text || '').replace(/'/g, "\\'").replace(/"/g, '"'); + } function escapeHtml(text) { const div = document.createElement('div'); @@ -1748,9 +1894,14 @@ sp.classList.add('current-word'); // keep highlighted word in view inside subtitle box const box = document.getElementById('plSubtitle'); - const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight; - if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) { - box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' }); + const spTop = sp.offsetTop; + const spBottom = spTop + sp.offsetHeight; + const boxScrollTop = box.scrollTop; + const boxHeight = box.clientHeight; + + // Scroll if the word is near the edges (30px buffer) + if (spTop < boxScrollTop + 30 || spBottom > boxScrollTop + boxHeight - 30) { + box.scrollTo({ top: spTop - (boxHeight / 2) + (sp.offsetHeight / 2), behavior: 'smooth' }); } playerState.lastWordSpan = sp; } @@ -1829,4 +1980,4 @@ loadBooks(); - + \ No newline at end of file diff --git a/thumbnail_generator.py b/thumbnail_generator.py new file mode 100644 index 0000000..a4a601a --- /dev/null +++ b/thumbnail_generator.py @@ -0,0 +1,262 @@ +# thumbnail_generator.py - Auto thumbnail generation from document first page (v4.4) +# +# PDF → PyMuPDF (fitz) দিয়ে প্রথম পেজ রেন্ডার করে PNG থাম্বনেইল +# DOCX → docProps/thumbnail.* (embedded preview) অথবা প্রথম embedded image +# +# আউটপুট সবসময় optimize করা bytes (PNG/JPEG), যা media_storage.save_thumbnail() এ যাবে। + +import io +import zipfile + + +# থাম্বনেইলের টার্গেট সাইজ (বইয়ের কভার — portrait 2:3 ratio) +THUMB_MAX_WIDTH = 600 +THUMB_MAX_HEIGHT = 900 +JPEG_QUALITY = 82 + + +def _optimize_image_bytes(img_bytes, source_format='png'): + """ + Pillow থাকলে রিসাইজ + কম্প্রেস করে। না থাকলে raw bytes-ই ফেরত দেয়। + রিটার্ন: (optimized_bytes, format_str) + """ + try: + from PIL import Image + except ImportError: + return img_bytes, source_format + + try: + img = Image.open(io.BytesIO(img_bytes)) + + # RGBA/palette → RGB (JPEG এর জন্য) + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + if img.mode in ('RGBA', 'LA'): + background.paste(img, mask=img.split()[-1]) + img = background + else: + img = img.convert('RGB') + elif img.mode != 'RGB': + img = img.convert('RGB') + + # অনুপাত ধরে রেখে থাম্বনেইল সাইজে নামানো + img.thumbnail((THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), Image.LANCZOS) + + out = io.BytesIO() + img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) + out.seek(0) + return out.read(), 'jpeg' + except Exception as e: + print(f" ⚠️ Thumbnail optimize failed: {e}") + return img_bytes, source_format + + +def generate_pdf_thumbnail(pdf_bytes): + """ + PDF-এর প্রথম পেজ রেন্ডার করে optimize করা থাম্বনেইল bytes ফেরত দেয়। + রিটার্ন: (bytes, format) অথবা (None, None) + """ + try: + import fitz # PyMuPDF + except ImportError: + print(" ⚠️ PyMuPDF not available for thumbnail") + return None, None + + try: + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + if doc.page_count == 0: + doc.close() + return None, None + + page = doc.load_page(0) + + # রেন্ডার রেজোলিউশন — টার্গেট উচ্চতার উপর ভিত্তি করে zoom নির্ধারণ + page_height = page.rect.height or 792 + zoom = max(1.0, min(3.0, (THUMB_MAX_HEIGHT * 1.3) / page_height)) + matrix = fitz.Matrix(zoom, zoom) + + pix = page.get_pixmap(matrix=matrix, alpha=False) + png_bytes = pix.tobytes("png") + doc.close() + + return _optimize_image_bytes(png_bytes, 'png') + except Exception as e: + print(f" ⚠️ PDF thumbnail generation failed: {e}") + return None, None + + +def generate_docx_thumbnail(docx_bytes, extracted_blocks=None): + """ + DOCX থেকে থাম্বনেইল বানানোর চেষ্টা করে (রেন্ডারিং লাইব্রেরি ছাড়া)। + + কৌশল: + 1. docProps/thumbnail.* (Word এ Save করার সময় "Save Thumbnail" অন থাকলে) + 2. প্রথম embedded image (word/media/) — যদি ছবিটি যথেষ্ট বড় হয় + 3. extracted_blocks থেকে প্রথম image block-এর base64 data + + রিটার্ন: (bytes, format) অথবা (None, None) + """ + # কৌশল ১ + ২: zip আর্কাইভ থেকে + try: + with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: + names = zf.namelist() + + # ১. embedded thumbnail + for name in names: + lower = name.lower() + if lower.startswith('docprops/thumbnail'): + data = zf.read(name) + if data and len(data) > 500: + fmt = lower.rsplit('.', 1)[-1] if '.' in lower else 'png' + return _optimize_image_bytes(data, fmt) + + # ২. word/media/ থেকে প্রথম বড় ইমেজ + media = sorted([n for n in names if n.lower().startswith('word/media/')]) + for name in media: + lower = name.lower() + if not any(lower.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): + continue + data = zf.read(name) + # ছোট আইকন/লোগো এড়াতে ন্যূনতম সাইজ চেক + if data and len(data) > 8000: + fmt = lower.rsplit('.', 1)[-1] + return _optimize_image_bytes(data, fmt) + except Exception as e: + print(f" ⚠️ DOCX zip thumbnail failed: {e}") + + # কৌশল ৩: প্রসেস করা blocks থেকে প্রথম ইমেজ + if extracted_blocks: + import base64 + for block in extracted_blocks: + if block.get('type') == 'image' and block.get('data'): + try: + raw = base64.b64decode(block['data']) + if len(raw) > 4000: + return _optimize_image_bytes(raw, block.get('format', 'png')) + except Exception: + continue + + return None, None + + +# টাইটেলের হ্যাশ থেকে ধারাবাহিক রঙ বাছাই করার জন্য কভার প্যালেট +_COVER_PALETTES = [ + ((37, 52, 74), (62, 84, 120)), # নীল + ((58, 42, 74), (92, 66, 120)), # বেগুনি + ((44, 62, 55), (66, 98, 82)), # সবুজ + ((74, 44, 42), (120, 72, 66)), # লালচে বাদামি + ((44, 54, 74), (70, 88, 120)), # স্টিল ব্লু + ((60, 50, 40), (110, 90, 66)), # সেপিয়া +] + + +def generate_text_cover(title, author='', subtitle=''): + """ + Pillow দিয়ে একটা পরিপাটি টেক্সট-ভিত্তিক বইয়ের কভার তৈরি করে। + কোনো ইমেজ না থাকলে fallback হিসেবে ব্যবহৃত হয়। + রিটার্ন: (bytes, 'jpeg') অথবা (None, None) + """ + try: + from PIL import Image, ImageDraw, ImageFont + except ImportError: + return None, None + + try: + W, H = THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT + + # টাইটেল অনুযায়ী ধারাবাহিক রঙ (একই বই সবসময় একই রঙ পাবে) + clean_title = (title or 'Untitled').strip() + palette_idx = sum(ord(c) for c in clean_title) % len(_COVER_PALETTES) + top_color, bottom_color = _COVER_PALETTES[palette_idx] + + img = Image.new('RGB', (W, H), top_color) + draw = ImageDraw.Draw(img) + for y in range(H): + ratio = y / H + r = int(top_color[0] + (bottom_color[0] - top_color[0]) * ratio) + g = int(top_color[1] + (bottom_color[1] - top_color[1]) * ratio) + b = int(top_color[2] + (bottom_color[2] - top_color[2]) * ratio) + draw.line([(0, y), (W, y)], fill=(r, g, b)) + + # ডাবল বর্ডার ফ্রেম + draw.rectangle([26, 26, W - 26, H - 26], outline=(255, 255, 255), width=2) + draw.rectangle([36, 36, W - 36, H - 36], outline=(255, 255, 255), width=1) + + # ফন্ট লোড + def _font(size): + for name in ("DejaVuSans-Bold.ttf", "arial.ttf", "Arial Bold.ttf"): + try: + return ImageFont.truetype(name, size) + except Exception: + continue + return ImageFont.load_default() + + def _font_light(size): + for name in ("DejaVuSans.ttf", "arial.ttf"): + try: + return ImageFont.truetype(name, size) + except Exception: + continue + return ImageFont.load_default() + + title_font = _font(48) + author_font = _font_light(26) + + def _text_w(text, font): + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + # উপরে ডেকোরেটিভ ডাবল-লাইন সেপারেটর + deco_y = 130 + draw.line([(W // 2 - 60, deco_y), (W // 2 + 60, deco_y)], + fill=(255, 255, 255), width=2) + draw.line([(W // 2 - 40, deco_y + 10), (W // 2 + 40, deco_y + 10)], + fill=(255, 255, 255), width=1) + + # টাইটেল word-wrap (কেন্দ্রে) + def _wrap(text, font, max_width): + words = text.split() + lines, cur = [], '' + for w in words: + test = (cur + ' ' + w).strip() + if _text_w(test, font) <= max_width: + cur = test + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines[:6] + + title_lines = _wrap(clean_title, title_font, W - 110) + line_h = 60 + total_h = len(title_lines) * line_h + y = (H - total_h) // 2 - 20 + + for line in title_lines: + lw = _text_w(line, title_font) + # হালকা shadow (গভীরতার জন্য) + draw.text(((W - lw) // 2 + 2, y + 2), line, font=title_font, fill=(0, 0, 0)) + draw.text(((W - lw) // 2, y), line, font=title_font, fill=(255, 255, 255)) + y += line_h + + # নিচে সেপারেটর + author + bottom_y = H - 130 + draw.line([(W // 2 - 50, bottom_y), (W // 2 + 50, bottom_y)], + fill=(255, 255, 255), width=1) + + author_text = f"by {author}" if author else "Audiobook" + aw = _text_w(author_text, author_font) + draw.text(((W - aw) // 2, bottom_y + 20), author_text, + font=author_font, fill=(215, 222, 230)) + + out = io.BytesIO() + img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) + out.seek(0) + return out.read(), 'jpeg' + except Exception as e: + print(f" ⚠️ Text cover generation failed: {e}") + return None, None \ No newline at end of file