diff --git a/search.py b/search.py index 387028f..e08211e 100644 --- a/search.py +++ b/search.py @@ -15,9 +15,11 @@ with open("app_config.json", 'r') as file: FILETYPE_GROUPS = { 'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'), 'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'), - 'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff') + 'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff'), + 'liedtext': ('.sng',) } ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group})) +ALL_GROUP_KEYS = set(FILETYPE_GROUPS.keys()) def searchcommand(): query = request.form.get("query", "").strip() @@ -88,7 +90,7 @@ def searchcommand(): include_other = 'other' in filetypes # If not all groups selected, apply filter - if set(filetypes) != {'audio', 'video', 'image', 'other'}: + if set(filetypes) != (ALL_GROUP_KEYS | {'other'}): clauses = [] if selected_groups: ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]}) diff --git a/static/app.css b/static/app.css index e53a3e5..c25e1d0 100644 --- a/static/app.css +++ b/static/app.css @@ -457,6 +457,83 @@ footer .audio-player-container { overflow-x: auto; } +#transcriptContent .song-sheet { + display: flex; + flex-direction: column; + gap: 12px; +} + +#transcriptContent .song-header h2 { + margin: 0; + font-size: 22px; +} + +#transcriptContent .song-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + padding: 10px 12px; + background: #f8fbff; + border: 1px solid var(--border-color); + border-radius: 10px; +} + +#transcriptContent .song-meta-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-text); + margin-bottom: 4px; +} + +#transcriptContent .song-meta-value { + font-weight: 600; + color: var(--brand-ink); +} + +#transcriptContent .song-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +#transcriptContent .song-chip { + padding: 2px 8px; + border-radius: 999px; + background: #eef4ff; + color: var(--brand-ink); + font-size: 12px; + font-weight: 600; +} + +#transcriptContent .song-section { + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + box-shadow: var(--card-shadow); +} + +#transcriptContent .song-section h4 { + margin: 0 0 8px; + font-size: 16px; + color: var(--brand-navy); +} + +#transcriptContent .song-section p { + margin: 0; + white-space: pre-line; + line-height: 1.6; +} + +#transcriptContent .song-translation { + color: var(--muted-text); +} + +#transcriptContent .song-small { + font-size: 0.9em; +} + /* Center the reload control */ #directory-controls { diff --git a/static/app.js b/static/app.js index 1fbdf42..8142c57 100644 --- a/static/app.js +++ b/static/app.js @@ -307,6 +307,8 @@ function renderContent(data) { currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') }); } else if (file.file_type === 'image') { symbol = ''; + } else if ((file.name || '').toLowerCase().endsWith('.sng')) { + symbol = ''; } const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; contentHTML += `
  • @@ -485,6 +487,12 @@ document.querySelectorAll('.play-file').forEach(link => { const { fileType, url: relUrl, index } = this.dataset; const now = Date.now(); + const lowerUrl = (relUrl || '').toLowerCase(); + + if (lowerUrl.endsWith('.sng')) { + openSngModal(relUrl); + return; + } if (fileType === 'music') { // If this is the same track already loaded, ignore to avoid extra GETs and unselects. @@ -609,6 +617,167 @@ document.querySelectorAll('.play-file').forEach(link => { } // End of attachEventListeners function +function escapeHtml(text) { + return (text || '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +function formatSngLine(line) { + let safe = escapeHtml(line); + safe = safe.replace(/<dfn>/gi, ''); + safe = safe.replace(/<\/dfn>/gi, ''); + safe = safe.replace(/<small>/gi, ''); + safe = safe.replace(/<\/small>/gi, ''); + return safe; +} + +function parseSngText(rawText) { + const text = (rawText || '').replace(/\r/g, ''); + const lines = text.split('\n'); + const meta = {}; + const sections = []; + let current = null; + let inBody = false; + + const pushCurrent = () => { + if (!current) return; + while (current.lines.length && current.lines[current.lines.length - 1].trim() === '') { + current.lines.pop(); + } + if (current.title || current.lines.length) { + sections.push(current); + } + current = null; + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\uFEFF/g, ''); + const trimmed = line.trim(); + + if (!inBody && trimmed.startsWith('#')) { + const idx = trimmed.indexOf('='); + if (idx > 1) { + const key = trimmed.slice(1, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (key) meta[key] = value; + } + continue; + } + + if (trimmed === '---' || trimmed === '-----' || trimmed === '----') { + inBody = true; + pushCurrent(); + continue; + } + + if (!inBody && trimmed === '') { + continue; + } + + inBody = true; + if (!current) { + if (!trimmed) continue; + current = { title: trimmed, lines: [] }; + continue; + } + current.lines.push(line); + } + pushCurrent(); + + if (!sections.length) { + const bodyLines = lines + .filter(l => l && !l.trim().startsWith('#') && !['---', '-----', '----'].includes(l.trim())); + if (bodyLines.length) { + sections.push({ title: '', lines: bodyLines }); + } + } + + return { meta, sections }; +} + +function buildSngHtml(parsed, fallbackTitle) { + const meta = parsed.meta || {}; + const sections = parsed.sections || []; + const title = meta.Title || fallbackTitle || 'Liedtext'; + + const metaRows = []; + const pushMeta = (label, value, asChips = false) => { + if (!value) return; + if (asChips) { + const chips = value + .split(',') + .map(v => v.trim()) + .filter(Boolean) + .map(v => `${escapeHtml(v)}`) + .join(''); + if (chips) { + metaRows.push(`
    ${escapeHtml(label)}
    ${chips}
    `); + } + return; + } + metaRows.push(`
    ${escapeHtml(label)}
    ${escapeHtml(value)}
    `); + }; + + pushMeta('Liederbuch', meta.Songbook); + pushMeta('ID', meta.ChurchSongID); + pushMeta('Stichworte', meta.Keywords, true); + pushMeta('Reihenfolge', meta.VerseOrder, true); + pushMeta('Sprache', meta.LangCount); + + let html = '
    '; + html += `

    ${escapeHtml(title)}

    `; + if (metaRows.length) { + html += `
    ${metaRows.join('')}
    `; + } + + sections.forEach(section => { + const heading = section.title ? `

    ${escapeHtml(section.title)}

    ` : ''; + const body = section.lines.map(l => formatSngLine(l)).join('
    '); + html += `
    ${heading}

    ${body}

    `; + }); + + html += '
    '; + return html; +} + +function openSngModal(relUrl) { + if (!relUrl) return; + const modal = document.getElementById('transcriptModal'); + const content = document.getElementById('transcriptContent'); + if (!modal || !content) return; + + const encodedPath = encodeSubpath(relUrl); + const filename = decodeURIComponent(relUrl.split('/').pop() || '').replace(/\.sng$/i, ''); + content.innerHTML = '

    Lade Liedtext…

    '; + modal.style.display = 'block'; + + fetch(`/media/${encodedPath}`) + .then(response => { + if (!response.ok) throw new Error('Laden fehlgeschlagen'); + return response.arrayBuffer(); + }) + .then(buffer => { + let text = ''; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch (err) { + text = new TextDecoder('windows-1252').decode(buffer); + } + const parsed = parseSngText(text); + content.innerHTML = buildSngHtml(parsed, filename); + }) + .catch(() => { + content.innerHTML = '

    Der Liedtext konnte nicht geladen werden.

    '; + }); +} + +window.openSngModal = openSngModal; + function initTranscriptModal() { const modal = document.getElementById('transcriptModal'); if (!modal || modal.dataset.bound) return; diff --git a/static/calendar.js b/static/calendar.js index 843f58f..ce9e09f 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -286,6 +286,10 @@ // Helper to insert entries sorted by time inside an existing day block function addCalendarEntry(entry) { if (!entry || !entry.date) return; + if (entry.id) { + const existing = document.querySelector(`.calendar-entry-row[data-id="${entry.id}"]`); + if (existing) return; + } const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`); if (!block) return; @@ -340,6 +344,7 @@ } async function loadCalendarEntries() { + const fetchToken = (window._calendarFetchToken = (window._calendarFetchToken || 0) + 1); const { start, end } = getCalendarRange(); const todayIso = toLocalISO(start); const endIso = toLocalISO(end); @@ -351,6 +356,7 @@ const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`); if (!response.ok) throw new Error('Fehler beim Laden'); const entries = await response.json(); + if (fetchToken !== window._calendarFetchToken) return; entries.forEach(entry => { addLocationOption(entry.location); addCalendarEntry(entry); diff --git a/static/search.js b/static/search.js index 9173b18..741651f 100644 --- a/static/search.js +++ b/static/search.js @@ -21,12 +21,18 @@ function initSearch() { const parentFolder = file.relative_path.split('/').slice(0, -1).join('/'); const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md'; const isAudio = audioExts.includes((file.filetype || '').toLowerCase()); + const isSng = (file.filetype || '').toLowerCase() === '.sng' || (file.filename || '').toLowerCase().endsWith('.sng'); const encodedRelPath = encodeURI(file.relative_path); card.className = 'card'; - const fileAction = isAudio - ? `` - : ` ${file.filename}`; + let fileAction = ''; + if (isSng) { + fileAction = ``; + } else if (isAudio) { + fileAction = ``; + } else { + fileAction = ` ${file.filename}`; + } card.innerHTML = `
    @@ -47,6 +53,7 @@ function initSearch() { } attachEventListeners(); attachSearchFolderButtons(); + attachSearchSngButtons(); } else { resultsDiv.innerHTML = `
    ${total} Treffer

    Keine Treffer gefunden.

    `; } @@ -229,6 +236,18 @@ function attachSearchFolderButtons() { }); } +function attachSearchSngButtons() { + document.querySelectorAll('.open-sng-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const path = btn.dataset.path; + if (typeof window.openSngModal === 'function') { + window.openSngModal(path); + } + }); + }); +} + function openFolderAndHighlight(folderPath, filePath) { const targetFolder = folderPath || ''; // Switch back to main view before loading folder diff --git a/templates/app.html b/templates/app.html index 53956bb..5e26aa0 100644 --- a/templates/app.html +++ b/templates/app.html @@ -143,6 +143,10 @@
    +
    + + +