preview for Liedtext
This commit is contained in:
parent
6f761435f3
commit
6cc1fb8d56
@ -15,9 +15,11 @@ with open("app_config.json", 'r') as file:
|
|||||||
FILETYPE_GROUPS = {
|
FILETYPE_GROUPS = {
|
||||||
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
|
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
|
||||||
'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'),
|
'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_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
|
||||||
|
ALL_GROUP_KEYS = set(FILETYPE_GROUPS.keys())
|
||||||
|
|
||||||
def searchcommand():
|
def searchcommand():
|
||||||
query = request.form.get("query", "").strip()
|
query = request.form.get("query", "").strip()
|
||||||
@ -88,7 +90,7 @@ def searchcommand():
|
|||||||
include_other = 'other' in filetypes
|
include_other = 'other' in filetypes
|
||||||
|
|
||||||
# If not all groups selected, apply filter
|
# If not all groups selected, apply filter
|
||||||
if set(filetypes) != {'audio', 'video', 'image', 'other'}:
|
if set(filetypes) != (ALL_GROUP_KEYS | {'other'}):
|
||||||
clauses = []
|
clauses = []
|
||||||
if selected_groups:
|
if selected_groups:
|
||||||
ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]})
|
ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]})
|
||||||
|
|||||||
@ -457,6 +457,83 @@ footer .audio-player-container {
|
|||||||
overflow-x: auto;
|
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 */
|
/* Center the reload control */
|
||||||
#directory-controls {
|
#directory-controls {
|
||||||
|
|||||||
169
static/app.js
169
static/app.js
@ -307,6 +307,8 @@ function renderContent(data) {
|
|||||||
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
|
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
|
||||||
} else if (file.file_type === 'image') {
|
} else if (file.file_type === 'image') {
|
||||||
symbol = '<i class="bi bi-image"></i>';
|
symbol = '<i class="bi bi-image"></i>';
|
||||||
|
} else if ((file.name || '').toLowerCase().endsWith('.sng')) {
|
||||||
|
symbol = '<i class="bi bi-music-note-list"></i>';
|
||||||
}
|
}
|
||||||
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
|
||||||
contentHTML += `<li class="file-item">
|
contentHTML += `<li class="file-item">
|
||||||
@ -485,6 +487,12 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
|
|
||||||
const { fileType, url: relUrl, index } = this.dataset;
|
const { fileType, url: relUrl, index } = this.dataset;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const lowerUrl = (relUrl || '').toLowerCase();
|
||||||
|
|
||||||
|
if (lowerUrl.endsWith('.sng')) {
|
||||||
|
openSngModal(relUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (fileType === 'music') {
|
if (fileType === 'music') {
|
||||||
// If this is the same track already loaded, ignore to avoid extra GETs and unselects.
|
// 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
|
} // 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, '<span class="song-translation">');
|
||||||
|
safe = safe.replace(/<\/dfn>/gi, '</span>');
|
||||||
|
safe = safe.replace(/<small>/gi, '<span class="song-small">');
|
||||||
|
safe = safe.replace(/<\/small>/gi, '</span>');
|
||||||
|
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 => `<span class="song-chip">${escapeHtml(v)}</span>`)
|
||||||
|
.join('');
|
||||||
|
if (chips) {
|
||||||
|
metaRows.push(`<div><div class="song-meta-label">${escapeHtml(label)}</div><div class="song-chip-row">${chips}</div></div>`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
metaRows.push(`<div><div class="song-meta-label">${escapeHtml(label)}</div><div class="song-meta-value">${escapeHtml(value)}</div></div>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pushMeta('Liederbuch', meta.Songbook);
|
||||||
|
pushMeta('ID', meta.ChurchSongID);
|
||||||
|
pushMeta('Stichworte', meta.Keywords, true);
|
||||||
|
pushMeta('Reihenfolge', meta.VerseOrder, true);
|
||||||
|
pushMeta('Sprache', meta.LangCount);
|
||||||
|
|
||||||
|
let html = '<div class="song-sheet">';
|
||||||
|
html += `<div class="song-header"><h2>${escapeHtml(title)}</h2></div>`;
|
||||||
|
if (metaRows.length) {
|
||||||
|
html += `<div class="song-meta">${metaRows.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.forEach(section => {
|
||||||
|
const heading = section.title ? `<h4>${escapeHtml(section.title)}</h4>` : '';
|
||||||
|
const body = section.lines.map(l => formatSngLine(l)).join('<br>');
|
||||||
|
html += `<section class="song-section">${heading}<p>${body}</p></section>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
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 = '<p>Lade Liedtext…</p>';
|
||||||
|
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 = '<p>Der Liedtext konnte nicht geladen werden.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openSngModal = openSngModal;
|
||||||
|
|
||||||
function initTranscriptModal() {
|
function initTranscriptModal() {
|
||||||
const modal = document.getElementById('transcriptModal');
|
const modal = document.getElementById('transcriptModal');
|
||||||
if (!modal || modal.dataset.bound) return;
|
if (!modal || modal.dataset.bound) return;
|
||||||
|
|||||||
@ -286,6 +286,10 @@
|
|||||||
// Helper to insert entries sorted by time inside an existing day block
|
// Helper to insert entries sorted by time inside an existing day block
|
||||||
function addCalendarEntry(entry) {
|
function addCalendarEntry(entry) {
|
||||||
if (!entry || !entry.date) return;
|
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}"]`);
|
const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`);
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
@ -340,6 +344,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendarEntries() {
|
async function loadCalendarEntries() {
|
||||||
|
const fetchToken = (window._calendarFetchToken = (window._calendarFetchToken || 0) + 1);
|
||||||
const { start, end } = getCalendarRange();
|
const { start, end } = getCalendarRange();
|
||||||
const todayIso = toLocalISO(start);
|
const todayIso = toLocalISO(start);
|
||||||
const endIso = toLocalISO(end);
|
const endIso = toLocalISO(end);
|
||||||
@ -351,6 +356,7 @@
|
|||||||
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
|
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
|
||||||
if (!response.ok) throw new Error('Fehler beim Laden');
|
if (!response.ok) throw new Error('Fehler beim Laden');
|
||||||
const entries = await response.json();
|
const entries = await response.json();
|
||||||
|
if (fetchToken !== window._calendarFetchToken) return;
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
addLocationOption(entry.location);
|
addLocationOption(entry.location);
|
||||||
addCalendarEntry(entry);
|
addCalendarEntry(entry);
|
||||||
|
|||||||
@ -21,12 +21,18 @@ function initSearch() {
|
|||||||
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
||||||
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
|
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
|
||||||
const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
|
const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
|
||||||
|
const isSng = (file.filetype || '').toLowerCase() === '.sng' || (file.filename || '').toLowerCase().endsWith('.sng');
|
||||||
const encodedRelPath = encodeURI(file.relative_path);
|
const encodedRelPath = encodeURI(file.relative_path);
|
||||||
|
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
const fileAction = isAudio
|
let fileAction = '';
|
||||||
? `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`
|
if (isSng) {
|
||||||
: `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
|
fileAction = `<button class="btn btn-light open-sng-btn" data-path="${file.relative_path}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${filenameWithoutExtension}</button>`;
|
||||||
|
} else if (isAudio) {
|
||||||
|
fileAction = `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`;
|
||||||
|
} else {
|
||||||
|
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -47,6 +53,7 @@ function initSearch() {
|
|||||||
}
|
}
|
||||||
attachEventListeners();
|
attachEventListeners();
|
||||||
attachSearchFolderButtons();
|
attachSearchFolderButtons();
|
||||||
|
attachSearchSngButtons();
|
||||||
} else {
|
} else {
|
||||||
resultsDiv.innerHTML = `<h5>${total} Treffer</h5><p>Keine Treffer gefunden.</p>`;
|
resultsDiv.innerHTML = `<h5>${total} Treffer</h5><p>Keine Treffer gefunden.</p>`;
|
||||||
}
|
}
|
||||||
@ -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) {
|
function openFolderAndHighlight(folderPath, filePath) {
|
||||||
const targetFolder = folderPath || '';
|
const targetFolder = folderPath || '';
|
||||||
// Switch back to main view before loading folder
|
// Switch back to main view before loading folder
|
||||||
|
|||||||
@ -143,6 +143,10 @@
|
|||||||
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
|
||||||
<label class="form-check-label" for="filetype-image">Bild</label>
|
<label class="form-check-label" for="filetype-image">Bild</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-liedtext" value="liedtext">
|
||||||
|
<label class="form-check-label" for="filetype-liedtext">Liedtext</label>
|
||||||
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
|
||||||
<label class="form-check-label" for="filetype-other">Sonstige</label>
|
<label class="form-check-label" for="filetype-other">Sonstige</label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user