diff --git a/api/router.py b/api/router.py index 57b2bbb..64a091e 100644 --- a/api/router.py +++ b/api/router.py @@ -136,7 +136,7 @@ async def get_transcripts(user: dict = Depends(current_user)): return list_transcripts(user_dir) -@router.get("/transcripts/{filename}") +@router.get("/transcripts/{filename:path}") async def get_transcript(filename: str, user: dict = Depends(current_user)): from fastapi.responses import PlainTextResponse user_dir = os.path.join(user["output_dir"], user["username"]) @@ -146,7 +146,7 @@ async def get_transcript(filename: str, user: dict = Depends(current_user)): return PlainTextResponse(content) -@router.post("/transcripts/{filename}/reprocess") +@router.post("/transcripts/{filename:path}/reprocess") async def reprocess_transcript(filename: str, body: dict, user: dict = Depends(current_user)): from output import read_transcript from fastapi.responses import PlainTextResponse @@ -175,10 +175,11 @@ async def reprocess_transcript(filename: str, body: dict, user: dict = Depends(c return PlainTextResponse(refined) -@router.delete("/transcripts/{filename}") +@router.delete("/transcripts/{filename:path}") async def delete_transcript(filename: str, user: dict = Depends(current_user)): user_dir = os.path.join(user["output_dir"], user["username"]) - if os.path.basename(filename) != filename or not filename.endswith(".md"): + parts = filename.split("/") + if len(parts) > 2 or any(p in (".", "..") or not p for p in parts) or not filename.endswith(".md"): raise HTTPException(status_code=404, detail="Nicht gefunden") path = os.path.join(user_dir, filename) if not os.path.exists(path): diff --git a/frontend/app.js b/frontend/app.js index d18a154..adeab62 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -12,9 +12,11 @@ const modalObsidianBtn = document.getElementById('modal-obsidian-btn'); const modalFolderBtn = document.getElementById('modal-folder-btn'); const modalOpenBtn = document.getElementById('modal-open-btn'); const modalCloseBtn = document.getElementById('modal-close-btn'); +const modalTabs = document.getElementById('modal-tabs'); let _modalPath = null; let _modalPaths = null; let _modalFilename = null; +let _modalRelated = null; const speakerCard = document.getElementById('speaker-card'); const speakerRows = document.getElementById('speaker-rows'); @@ -54,18 +56,47 @@ logoutBtn.addEventListener('click', () => { }); }); -function openModal(filename, path, paths) { +function _loadModalContent(filename, activeTab) { + modalBody.innerHTML = ''; + apiFetch(`/transcripts/${filename.split('/').map(encodeURIComponent).join('/')}`) + .then(r => r.text()) + .then(md => { modalBody.innerHTML = DOMPurify.sanitize(marked.parse(md)); }); + // update active tab + modalTabs.querySelectorAll('.modal-tab').forEach(t => { + t.classList.toggle('active', t.dataset.file === filename); + }); +} + +function openModal(filename, path, paths, related) { _modalPath = path; _modalPaths = paths || null; _modalFilename = filename; - modalTitle.textContent = filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, ''); - modalBody.innerHTML = ''; + _modalRelated = related || null; + modalTitle.textContent = filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, '').replace(/-index$/, ''); modal.classList.remove('hidden'); - apiFetch(`/transcripts/${encodeURIComponent(filename)}`) - .then(r => r.text()) - .then(md => { - modalBody.innerHTML = DOMPurify.sanitize(marked.parse(md)); + + // Build tabs if there are related files + modalTabs.innerHTML = ''; + if (related && (related.transkript || related.zusammenfassung)) { + modalTabs.style.display = 'flex'; + const tabDefs = [ + { label: 'Index', file: filename }, + { label: 'Transkript', file: related.transkript }, + { label: 'Zusammenfassung', file: related.zusammenfassung }, + ].filter(t => t.file); + tabDefs.forEach(({ label, file }) => { + const btn = document.createElement('button'); + btn.className = 'modal-tab'; + btn.textContent = label; + btn.dataset.file = file; + btn.addEventListener('click', () => _loadModalContent(file, file)); + modalTabs.appendChild(btn); }); + } else { + modalTabs.style.display = 'none'; + } + + _loadModalContent(filename, filename); } function closeModal() { @@ -73,6 +104,7 @@ function closeModal() { _modalPath = null; _modalPaths = null; _modalFilename = null; + _modalRelated = null; } modalCloseBtn.addEventListener('click', closeModal); @@ -239,7 +271,7 @@ async function loadTranscripts() { meta.className = 'meta'; meta.textContent = `${Math.round(t.size / 1024 * 10) / 10} KB`; - div.addEventListener('click', () => openModal(t.filename, t.path)); + div.addEventListener('click', () => openModal(t.filename, t.path, null, t.related || null)); const reprocessBtn = document.createElement('button'); reprocessBtn.className = 'del-btn'; diff --git a/frontend/index.html b/frontend/index.html index 3882189..5b5a9db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -163,6 +163,10 @@ .modal-body pre { background: var(--surface2); padding: 12px; border-radius: 6px; overflow-x: auto; margin: 0 0 0.8em; } .modal-body pre code { background: none; padding: 0; } .modal-body hr { border: none; border-top: 1px solid var(--border); margin: 1em 0; } + .modal-tabs { display: flex; gap: 4px; padding: 10px 18px 0; border-bottom: 1px solid var(--border); flex-shrink: 0; } + .modal-tab { background: none; border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0; padding: 5px 12px; font-size: 0.78rem; font-family: inherit; color: var(--muted); cursor: pointer; transition: color 0.15s, border-color 0.15s; margin-bottom: -1px; } + .modal-tab:hover { color: var(--text); } + .modal-tab.active { color: var(--text); border-color: var(--border); background: var(--surface); } .del-btn { background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px; border-radius: 4px; display: flex; align-items: center; @@ -274,6 +278,7 @@ +
diff --git a/output.py b/output.py index 6b9f6f4..3442167 100644 --- a/output.py +++ b/output.py @@ -36,10 +36,15 @@ def save_transcript( def read_transcript(output_dir: str, filename: str) -> str | None: - """Return file content if filename is a plain .md file inside output_dir.""" - if os.path.basename(filename) != filename or not filename.endswith(".md"): + """Return file content for a .md file inside output_dir (flat or one level deep).""" + if not filename.endswith(".md"): + return None + parts = filename.split("/") + if len(parts) > 2 or any(p in (".", "..") or not p for p in parts): return None path = os.path.join(output_dir, filename) + if not os.path.abspath(path).startswith(os.path.abspath(output_dir) + os.sep): + return None if not os.path.exists(path): return None with open(path, encoding="utf-8") as f: @@ -57,7 +62,17 @@ def list_transcripts(output_dir: str, limit: int = 20) -> list[dict]: for f in files: full = os.path.join(output_dir, f) stat = os.stat(full) - result.append({"filename": f, "path": full, "size": stat.st_size, "mtime": stat.st_mtime}) + item: dict = {"filename": f, "path": full, "size": stat.st_size, "mtime": stat.st_mtime} + if f.endswith("-index.md"): + base = f[: -len("-index.md")] + related: dict[str, str] = {} + for suffix, key in [("-transkript.md", "transkript"), ("-zusammenfassung.md", "zusammenfassung")]: + rel_filename = f"{base}/{base}{suffix}" + if os.path.exists(os.path.join(output_dir, rel_filename)): + related[key] = rel_filename + if related: + item["related"] = related + result.append(item) return result