Add application, Dockerfile, CI workflow and documentation
Build & Push Docker Image / build-and-push (push) Failing after 5s

- FastAPI-based Matrix chat export tool with E2E support
- Dockerfile with Python 3.12-slim base image
- Docker Compose with dedicated network configuration
- Gitea Actions workflow for automated image builds
- README with setup instructions and feature overview
- MIT license

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Asmus
2026-07-03 08:04:27 +02:00
co-authored by Claude Opus 4.6
parent 6c3bea273a
commit 4dae5477ce
11 changed files with 2161 additions and 22 deletions
+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Chat Export</title>
<style>
:root {
--bg:#0b141a;--bg-chat:#0b141a;--msg-own:#005c4b;--msg-other:#202c33;
--msg-own-h:#006d5a;--msg-other-h:#263845;--text:#e9edef;--text2:#8696a0;
--border:#222d34;--accent:#00a884;--accent2:#06cf9c;--pill-bg:#182229;
--header:#202c33;--link:#53bdeb;--shadow:0 1px 2px rgba(0,0,0,.3);
--sys-bg:#182229;--sys-text:#8696a0;
}
@media(prefers-color-scheme:light){:root{
--bg:#efeae2;--bg-chat:#efeae2;--msg-own:#d9fdd3;--msg-other:#fff;
--msg-own-h:#c8f0c2;--msg-other-h:#f5f5f5;--text:#111b21;--text2:#667781;
--border:#e9edef;--accent:#008069;--accent2:#00a884;--pill-bg:#fff;
--header:#008069;--link:#027eb5;--shadow:0 1px 3px rgba(0,0,0,.12);
--sys-bg:rgba(255,255,255,.9);--sys-text:#54656f;
}}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
background:var(--bg);color:var(--text);line-height:1.45;font-size:14.2px}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-thumb{background:#374045;border-radius:3px}
.app{max-width:1200px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column;
background:var(--bg-chat);box-shadow:0 0 60px rgba(0,0,0,.3)}
.header{background:var(--header);padding:14px 20px;display:flex;align-items:center;
gap:14px;position:sticky;top:0;z-index:100;border-bottom:1px solid var(--border)}
.header-avatar{width:42px;height:42px;border-radius:50%;background:var(--accent);
display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;flex-shrink:0}
.header-info h1{font-size:16px;font-weight:600;color:#fff}
.header-info .sub{font-size:12px;color:rgba(255,255,255,.7);margin-top:1px}
.chat{flex:1;padding:8px 40px 20px;overflow-y:auto}
.date-separator{display:flex;justify-content:center;margin:16px 0 8px}
.date-pill{background:var(--pill-bg);color:var(--text2);padding:5px 12px;
border-radius:8px;font-size:12.5px;box-shadow:var(--shadow)}
.msg-group{margin-bottom:2px;display:flex}
.msg-group.own{justify-content:flex-end}
.msg-group.other{justify-content:flex-start}
.msg{max-width:75%;min-width:120px;padding:6px 7px 8px 9px;border-radius:8px;
position:relative;box-shadow:var(--shadow);word-wrap:break-word;overflow-wrap:break-word}
.msg-group.own .msg{background:var(--msg-own);border-top-right-radius:0}
.msg-group.other .msg{background:var(--msg-other);border-top-left-radius:0}
.msg-group.own .msg:hover{background:var(--msg-own-h)}
.msg-group.other .msg:hover{background:var(--msg-other-h)}
.msg .sender{font-size:13px;font-weight:600;margin-bottom:2px;display:block}
.msg .body{white-space:pre-wrap;font-size:14.2px;line-height:1.45;overflow-x:auto}
.msg .body pre{background:rgba(0,0,0,.2);padding:8px 10px;border-radius:4px;
overflow-x:auto;white-space:pre;font-size:13px;line-height:1.4;margin:4px 0}
.msg .body code{background:rgba(0,0,0,.15);padding:1px 4px;border-radius:3px;font-size:13px}
.msg .body pre code{background:none;padding:0}
.msg .body a{color:var(--link);text-decoration:none}
.msg .body a:hover{text-decoration:underline}
.msg .meta{display:flex;justify-content:flex-end;gap:4px;margin-top:2px;
float:right;margin-left:12px;position:relative;top:5px}
.msg .meta .time{font-size:11px;color:var(--text2)}
.msg .media-wrap{margin:4px -3px;border-radius:6px;overflow:hidden}
.msg .media-wrap img{max-width:100%;max-height:400px;display:block;cursor:pointer;
border-radius:6px;transition:opacity .15s}
.msg .media-wrap img:hover{opacity:.92}
.msg .media-wrap video{max-width:100%;max-height:400px;display:block;border-radius:6px;background:#000}
.msg .media-wrap audio{width:100%;min-width:240px;height:40px;margin:4px 0}
.msg .file-attach{display:flex;align-items:center;gap:10px;padding:8px 10px;
background:rgba(0,0,0,.1);border-radius:6px;margin:4px 0;text-decoration:none;
color:var(--text);transition:background .15s}
.msg .file-attach:hover{background:rgba(0,0,0,.18)}
.msg .file-attach .file-icon{font-size:28px;flex-shrink:0}
.msg .file-attach .file-name{font-size:13.5px;font-weight:500;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.msg .file-attach .file-size{font-size:11.5px;color:var(--text2)}
.system-event{display:flex;justify-content:center;margin:6px 0}
.system-event span{background:var(--sys-bg);color:var(--sys-text);padding:4px 12px;
border-radius:6px;font-size:12px;box-shadow:var(--shadow);text-align:center}
.emote-msg{font-style:italic;color:var(--text2)}
.redacted,.encrypted-msg{font-style:italic;color:var(--text2);font-size:13px}
.footer{background:var(--header);padding:12px 20px;text-align:center;
font-size:12px;color:var(--text2);border-top:1px solid var(--border)}
.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center}
.lightbox.active{display:flex}
.lightbox img{max-width:95%;max-height:95%;object-fit:contain;border-radius:4px}
.scroll-top{position:fixed;bottom:20px;right:20px;width:44px;height:44px;
border-radius:50%;background:var(--accent);color:#fff;border:none;cursor:pointer;
display:none;align-items:center;justify-content:center;font-size:20px;
box-shadow:0 2px 10px rgba(0,0,0,.3);z-index:50}
.scroll-top.visible{display:flex}
#loading{display:flex;justify-content:center;align-items:center;padding:40px;color:var(--text2);font-size:15px}
@media(max-width:600px){.chat{padding:8px 12px 20px}.msg{max-width:92%}}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div class="header-avatar">&#128172;</div>
<div class="header-info">
<h1 id="room-name">Lade...</h1>
<div class="sub" id="room-info"></div>
</div>
</header>
<div class="chat" id="chat">
<div id="loading">Nachrichten werden geladen...</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="">
</div>
<button class="scroll-top" id="scrollTop" title="Nach oben">&#8593;</button>
<div class="footer" id="footer"></div>
<script>
var D = __DATA__;
document.title = "Chat: " + D.roomName;
document.getElementById("room-name").textContent = D.roomName;
document.getElementById("room-info").textContent = D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate;
document.getElementById("footer").innerHTML = "Matrix Chat Export · " + D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate + " · Erstellt am " + D.exportDate;
var CHUNK = 200;
var rendered = 0;
var lastSender = "";
var chat = document.getElementById("chat");
var msgs = D.messages;
document.getElementById("loading").style.display = "none";
function esc(s) {
if (!s) return "";
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function linkify(t) {
return t.replace(/(https?:\/\/[^\s<>&]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
}
function renderBody(m) {
var mt = m.mt;
if (mt === "m.text" || mt === "m.notice") {
return m.fb ? '<div class="body">' + m.fb + "</div>"
: '<div class="body">' + linkify(esc(m.b)) + "</div>";
}
if (mt === "m.emote") {
return '<div class="body emote-msg">* ' + esc(m.b) + "</div>";
}
if (mt === "m.image") {
if (m.m) return '<div class="media-wrap"><a href="media/' + m.m + '" target="_blank"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" loading="lazy" onclick="openLightbox(this.src);return false"></a></div>';
return '<div class="body">[Bild: ' + esc(m.b) + "]</div>";
}
if (mt === "m.video") {
if (m.m) return '<div class="media-wrap"><video controls preload="metadata" src="media/' + m.m + '"></video></div>';
return '<div class="body">[Video: ' + esc(m.b) + "]</div>";
}
if (mt === "m.audio") {
var label = m.voice ? "🎤 Sprachnachricht" : "🎵 " + esc(m.b);
if (m.m) return '<div class="body" style="font-size:12.5px;color:var(--text2);margin-bottom:2px">' + label + '</div><div class="media-wrap"><audio controls preload="metadata" src="media/' + m.m + '"></audio></div>';
return '<div class="body">[Audio: ' + esc(m.b) + "]</div>";
}
if (mt === "m.file") {
if (m.m) return '<a class="file-attach" href="media/' + m.m + '" download="' + esc(m.b) + '"><span class="file-icon">&#128206;</span><div class="file-info"><div class="file-name">' + esc(m.b) + '</div><div class="file-size">' + (m.fs || "") + "</div></div></a>";
return '<div class="body">[Datei: ' + esc(m.b) + "]</div>";
}
if (mt === "m.location") {
if (m.geo) {
var coords = m.geo.replace("geo:", "").split(";")[0];
var p = coords.split(",");
return '<div class="body">&#128205; <a href="https://www.openstreetmap.org/?mlat=' + p[0] + "&mlon=" + (p[1] || "0") + "#map=16/" + p[0] + "/" + (p[1] || "0") + '" target="_blank" rel="noopener">' + esc(m.b) + "</a></div>";
}
return '<div class="body">&#128205; ' + esc(m.b) + "</div>";
}
return '<div class="body">' + esc(m.b || "") + "</div>";
}
function renderMessage(m) {
if (m.t === "date") {
lastSender = "";
return '<div class="date-separator"><span class="date-pill">' + esc(m.text) + "</span></div>";
}
if (m.t === "sys") {
lastSender = "";
return '<div class="system-event"><span>' + esc(m.text) + "</span></div>";
}
if (m.t === "avatar") {
lastSender = "";
var inner = "";
if (m.m) {
inner += '<img src="media/' + m.m + '" style="width:28px;height:28px;border-radius:50%;vertical-align:middle;margin-right:6px" loading="lazy">';
}
inner += esc(m.text);
return '<div class="system-event"><span>' + inner + "</span></div>";
}
if (m.t === "enc") {
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg"><div class="body encrypted-msg">&#128274; Verschlüsselte Nachricht</div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "sticker") {
if (!m.m) return "";
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg" style="background:transparent;box-shadow:none;padding:4px"><div class="media-wrap"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" style="max-width:160px;max-height:160px" loading="lazy"></div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "msg") {
var cls = m.o ? "own" : "other";
var sender = "";
if (!m.o && m.s !== lastSender) {
sender = '<span class="sender" style="color:' + m.c + '">' + esc(m.n) + "</span>";
}
lastSender = m.s;
var body = m.del ? '<div class="body redacted">Diese Nachricht wurde gelöscht</div>' : renderBody(m);
return '<div class="msg-group ' + cls + '"><div class="msg">' + sender + body + '<div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
return "";
}
function renderChunk() {
var end = Math.min(rendered + CHUNK, msgs.length);
var html = "";
for (var i = rendered; i < end; i++) {
html += renderMessage(msgs[i]);
}
var sentinel = document.getElementById("sentinel");
if (sentinel) sentinel.remove();
var frag = document.createElement("div");
frag.innerHTML = html;
while (frag.firstChild) chat.appendChild(frag.firstChild);
rendered = end;
if (rendered < msgs.length) {
var s = document.createElement("div");
s.id = "sentinel";
s.style.height = "1px";
chat.appendChild(s);
observer.observe(s);
}
}
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) renderChunk();
}, {rootMargin: "1200px"});
renderChunk();
function openLightbox(s) {
var l = document.getElementById("lightbox");
document.getElementById("lightbox-img").src = s;
l.classList.add("active");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("active");
}
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closeLightbox();
});
var scrollBtn = document.getElementById("scrollTop");
window.addEventListener("scroll", function() {
scrollBtn.classList.toggle("visible", window.scrollY > 800);
});
scrollBtn.addEventListener("click", function() {
window.scrollTo({top: 0, behavior: "smooth"});
});
</script>
</body>
</html>
+654
View File
@@ -0,0 +1,654 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Matrix Chat Export</title>
<style>
:root {
--bg: #0a0e14;
--surface: #131920;
--surface2: #1a2230;
--surface3: #222d3a;
--text: #e6edf3;
--text2: #8b949e;
--accent: #00a884;
--accent2: #06cf9c;
--danger: #f85149;
--warning: #d29922;
--border: #21262d;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
min-height: 100vh;
}
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 4px; }
h1 span { color: var(--accent); }
.subtitle { color: var(--text2); font-size: 14px; margin-bottom: 32px; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; margin-bottom: 16px;
transition: opacity .3s;
}
.card.disabled { opacity: .4; pointer-events: none; }
.card h2 {
font-size: 16px; font-weight: 600; margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.card h2 .num {
background: var(--accent); color: #fff; width: 28px; height: 28px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 13px; flex-shrink: 0;
}
.card h2 .check {
background: var(--accent2); color: #000; width: 28px; height: 28px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 15px; flex-shrink: 0;
}
.form-row { margin-bottom: 12px; }
.form-row label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 4px; }
.form-row input, .form-row select {
width: 100%; padding: 10px 14px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
font-size: 14px; outline: none; transition: border-color .2s;
}
.form-row input:focus { border-color: var(--accent); }
.form-row input[type="file"] { padding: 8px; }
.tabs { display: flex; gap: 0; margin-bottom: 16px; }
.tab {
flex: 1; padding: 8px; text-align: center; font-size: 13px; font-weight: 500;
cursor: pointer; background: var(--surface2); border: 1px solid var(--border);
color: var(--text2); transition: all .2s;
}
.tab:first-child { border-radius: 8px 0 0 8px; }
.tab:last-child { border-radius: 0 8px 8px 0; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px;
background: var(--accent); color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all .2s;
text-decoration: none;
}
.btn:hover { background: var(--accent2); color: #000; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.secondary { background: var(--surface3); color: var(--text); }
.btn.secondary:hover { background: var(--surface2); }
.btn.danger { background: var(--danger); }
.btn-row { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.status {
margin-top: 12px; padding: 10px 14px; border-radius: 8px;
font-size: 13px; display: none;
}
.status.success { display: block; background: rgba(0,168,132,.15); color: var(--accent2); border: 1px solid rgba(0,168,132,.3); }
.status.error { display: block; background: rgba(248,81,73,.15); color: var(--danger); border: 1px solid rgba(248,81,73,.3); }
.status.info { display: block; background: rgba(56,139,253,.15); color: #58a6ff; border: 1px solid rgba(56,139,253,.3); }
.room-search {
margin-bottom: 12px;
}
.room-search input {
width: 100%; padding: 10px 14px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
font-size: 14px; outline: none;
}
.room-search input:focus { border-color: var(--accent); }
.room-list { display: flex; flex-direction: column; gap: 4px; max-height: 400px; overflow-y: auto; }
.room-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.room-card:hover { border-color: var(--accent); background: var(--surface3); }
.room-card .name {
font-weight: 500; font-size: 14px; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.room-card .meta { font-size: 12px; color: var(--text2); white-space: nowrap; }
.room-card .badge {
font-size: 11px; padding: 2px 8px; border-radius: 20px;
background: var(--surface3); color: var(--text2); white-space: nowrap;
}
.room-card .badge.enc { background: rgba(210,153,34,.2); color: var(--warning); }
.log-box {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; max-height: 400px; overflow-y: auto; font-family: 'JetBrains Mono',
'Cascadia Code', 'Fira Code', monospace; font-size: 12.5px; line-height: 1.7;
}
.log-line { padding: 1px 0; }
.log-line.info { color: #58a6ff; }
.log-line.progress { color: var(--accent2); }
.log-line.warning { color: var(--warning); }
.log-line.error { color: var(--danger); }
.log-line.done { color: var(--accent2); font-weight: 600; }
.progress-bar {
width: 100%; height: 6px; background: var(--surface3); border-radius: 3px;
margin: 12px 0; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 3px; transition: width .3s ease; width: 0%;
}
.export-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: var(--surface2); border-radius: 8px;
margin-bottom: 8px; border: 1px solid var(--border); gap: 8px;
}
.export-item .info { flex: 1; min-width: 0; }
.export-item .info .name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.export-item .info .meta { font-size: 12px; color: var(--text2); }
.export-item .actions { display: flex; gap: 6px; flex-shrink: 0; }
.note {
background: var(--surface2); border-left: 3px solid var(--warning);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin-bottom: 16px;
font-size: 13px; color: var(--text2);
}
.note strong { color: var(--text); }
.hidden { display: none !important; }
@media (max-width: 600px) {
.container { padding: 16px 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1><span>&#9641;</span> Matrix Chat Export</h1>
<p class="subtitle">Exportiere verschlüsselte Matrix-Chats als schöne HTML-Dateien mit Medien</p>
<!-- STEP 1: Connect -->
<div class="card" id="card-connect">
<h2><span class="num" id="step1-icon">1</span> Verbinden</h2>
<div class="tabs">
<div class="tab active" onclick="switchTab('password')">Passwort</div>
<div class="tab" onclick="switchTab('token')">Access Token</div>
</div>
<div id="tab-password">
<div class="form-row">
<label>Homeserver URL</label>
<input type="url" id="hs-url" placeholder="https://matrix.example.com" value="">
</div>
<div class="form-row">
<label>Benutzername (ohne @...)</label>
<input type="text" id="username" placeholder="benutzername" autocomplete="username">
</div>
<div class="form-row">
<label>Passwort</label>
<input type="password" id="password" placeholder="Passwort" autocomplete="current-password">
</div>
</div>
<div id="tab-token" class="hidden">
<div class="form-row">
<label>Homeserver URL</label>
<input type="url" id="hs-url2" placeholder="https://matrix.example.com">
</div>
<div class="form-row">
<label>Access Token</label>
<input type="password" id="access-token" placeholder="syt_...">
</div>
</div>
<div class="btn-row">
<button class="btn" id="btn-connect" onclick="doConnect()">Verbinden</button>
<button class="btn danger hidden" id="btn-disconnect" onclick="doDisconnect()">Trennen</button>
</div>
<div class="status" id="connect-status"></div>
</div>
<!-- STEP 2: Import Keys -->
<div class="card disabled" id="card-keys">
<h2><span class="num" id="step2-icon">2</span> E2E-Schlüssel importieren</h2>
<div class="note">
<strong>Wichtig:</strong> Exportiere deine E2E-Schlüssel aus Element:
Einstellungen &rarr; Sicherheit &rarr; &quot;E2E-Raumschlüssel exportieren&quot;.
Die exportierte Datei und das Passwort werden hier benötigt.
</div>
<div class="form-row">
<label>Schlüsseldatei (.txt)</label>
<input type="file" id="key-file" accept=".txt,.key">
</div>
<div class="form-row">
<label>Export-Passphrase</label>
<input type="password" id="key-passphrase" placeholder="Passphrase vom Key-Export">
</div>
<div class="btn-row">
<button class="btn" id="btn-import" onclick="doImportKeys()">Schlüssel importieren</button>
<button class="btn secondary" onclick="skipKeys()">Überspringen (ohne E2E)</button>
</div>
<div class="status" id="keys-status"></div>
</div>
<!-- STEP 3: Rooms -->
<div class="card disabled" id="card-rooms">
<h2><span class="num" id="step3-icon">3</span> Raum auswählen</h2>
<div class="room-search">
<input type="search" id="room-search" placeholder="Raum suchen..." oninput="filterRooms()">
</div>
<div id="rooms-loading" class="hidden" style="color:var(--text2);font-size:14px">Lade Räume …</div>
<div class="room-list" id="room-list"></div>
<div class="status" id="rooms-status"></div>
<div class="btn-row" style="margin-top:12px">
<button class="btn secondary" onclick="loadRooms()">Räume neu laden</button>
</div>
</div>
<!-- STEP 4: Export Progress -->
<div class="card hidden" id="card-export">
<h2><span class="num" id="step4-icon">4</span> Export</h2>
<div class="progress-bar"><div class="fill" id="progress-fill"></div></div>
<div class="log-box" id="log-box"></div>
<div class="btn-row hidden" id="export-actions">
<a class="btn" id="btn-open-export" href="#" target="_blank">Export öffnen</a>
<a class="btn secondary hidden" id="btn-download-zip" href="#" download>&#128230; ZIP herunterladen</a>
<button class="btn secondary" onclick="location.reload()">Neuen Export starten</button>
</div>
</div>
<!-- Previous Exports -->
<div class="card" id="card-exports">
<h2>Vorherige Exports</h2>
<div id="exports-list"><span style="color:var(--text2);font-size:14px">Lade …</span></div>
</div>
</div>
<script>
var currentTab = "password";
var connected = false;
var keysImported = false;
var allRooms = [];
var roomsTotal = 0;
var roomsES = null;
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll(".tabs .tab").forEach(function(t) { t.classList.remove("active"); });
event.target.classList.add("active");
document.getElementById("tab-password").classList.toggle("hidden", tab !== "password");
document.getElementById("tab-token").classList.toggle("hidden", tab !== "token");
}
function setStatus(id, type, msg) {
var el = document.getElementById(id);
el.className = "status " + type;
el.textContent = msg;
}
function stepDone(num) {
var icon = document.getElementById("step" + num + "-icon");
icon.className = "check";
icon.textContent = "✓";
}
async function doConnect() {
var btn = document.getElementById("btn-connect");
btn.disabled = true;
btn.textContent = "Verbinde …";
try {
var body;
if (currentTab === "password") {
body = {
method: "password",
homeserver: document.getElementById("hs-url").value,
username: document.getElementById("username").value,
password: document.getElementById("password").value
};
} else {
body = {
method: "token",
homeserver: document.getElementById("hs-url2").value,
token: document.getElementById("access-token").value
};
}
var resp = await fetch("/api/connect", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body)
});
if (!resp.ok) {
var err = await resp.json();
throw new Error(err.detail || "Verbindung fehlgeschlagen");
}
var data = await resp.json();
connected = true;
setStatus("connect-status", "success",
"Verbunden als " + data.user_id + (data.device_id ? " (Device: " + data.device_id + ")" : ""));
stepDone(1);
document.getElementById("btn-disconnect").classList.remove("hidden");
document.getElementById("card-keys").classList.remove("disabled");
if (data.new_session) {
setStatus("keys-status", "info",
"Neues Device erstellt. Bitte importiere deine E2E-Schlüssel für entschlüsselte Exports.");
}
} catch (e) {
setStatus("connect-status", "error", e.message);
}
btn.disabled = false;
btn.textContent = "Verbinden";
}
async function doDisconnect() {
await fetch("/api/disconnect", {method: "POST"});
location.reload();
}
async function doImportKeys() {
var btn = document.getElementById("btn-import");
var fileInput = document.getElementById("key-file");
var passphrase = document.getElementById("key-passphrase").value;
if (!fileInput.files.length) {
setStatus("keys-status", "error", "Bitte Schlüsseldatei auswählen");
return;
}
btn.disabled = true;
btn.textContent = "Importiere …";
try {
var form = new FormData();
form.append("file", fileInput.files[0]);
form.append("passphrase", passphrase);
var resp = await fetch("/api/import-keys", {method: "POST", body: form});
if (!resp.ok) {
var err = await resp.json();
throw new Error(err.detail || "Import fehlgeschlagen");
}
var data = await resp.json();
keysImported = true;
setStatus("keys-status", "success", data.message);
stepDone(2);
enableRooms();
} catch (e) {
setStatus("keys-status", "error", e.message);
}
btn.disabled = false;
btn.textContent = "Schlüssel importieren";
}
function skipKeys() {
keysImported = false;
setStatus("keys-status", "info", "Übersprungen. Verschlüsselte Nachrichten werden nicht entschlüsselt.");
stepDone(2);
enableRooms();
}
function enableRooms() {
document.getElementById("card-rooms").classList.remove("disabled");
loadRooms();
}
function filterRooms() {
var q = document.getElementById("room-search").value.toLowerCase();
var cards = document.querySelectorAll("#room-list .room-card");
cards.forEach(function(card) {
var name = card.dataset.name.toLowerCase();
card.style.display = name.includes(q) ? "" : "none";
});
}
function addRoomCard(room) {
allRooms.push(room);
var list = document.getElementById("room-list");
var card = document.createElement("div");
card.className = "room-card";
card.dataset.name = room.name;
card.onclick = function() { startExport(room.room_id, room.name); };
card.innerHTML =
'<div class="name">' + escHtml(room.name) + "</div>" +
'<div class="meta">' + room.member_count + " Mitgl.</div>" +
(room.encrypted ? '<div class="badge enc">&#128274; E2E</div>' : '<div class="badge">Offen</div>');
var q = document.getElementById("room-search").value.toLowerCase();
if (q && !room.name.toLowerCase().includes(q)) {
card.style.display = "none";
}
list.appendChild(card);
}
function loadRooms() {
if (roomsES) { roomsES.close(); roomsES = null; }
var list = document.getElementById("room-list");
var loading = document.getElementById("rooms-loading");
list.innerHTML = "";
allRooms = [];
roomsTotal = 0;
loading.classList.remove("hidden");
loading.textContent = "Lade Räume …";
roomsES = new EventSource("/api/rooms/stream");
roomsES.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.type === "total") {
roomsTotal = data.count;
loading.textContent = "Lade 0/" + roomsTotal + " Räume …";
} else if (data.type === "room") {
setTimeout(function() {
addRoomCard(data.data);
loading.textContent = "Lade " + allRooms.length + "/" + roomsTotal + " Räume …";
}, 0);
} else if (data.type === "done") {
roomsES.close();
roomsES = null;
setTimeout(function() {
loading.classList.add("hidden");
if (allRooms.length === 0) {
list.innerHTML = '<p style="color:var(--text2)">Keine Räume gefunden</p>';
}
}, 50);
} else if (data.type === "error") {
roomsES.close();
roomsES = null;
loading.classList.add("hidden");
setStatus("rooms-status", "error", data.message);
}
};
roomsES.onerror = function() {
roomsES.close();
roomsES = null;
loading.classList.add("hidden");
};
}
async function startExport(roomId, roomName) {
if (!confirm('Chat "' + roomName + '" wirklich exportieren?')) return;
document.getElementById("card-export").classList.remove("hidden");
document.getElementById("card-export").scrollIntoView({behavior: "smooth"});
var logBox = document.getElementById("log-box");
logBox.innerHTML = "";
document.getElementById("progress-fill").style.width = "0%";
document.getElementById("export-actions").classList.add("hidden");
document.getElementById("btn-download-zip").classList.add("hidden");
addLog("info", "Starte Export für: " + roomName);
try {
var resp = await fetch("/api/export", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({room_id: roomId})
});
if (!resp.ok) {
var err = await resp.json();
addLog("error", err.detail || "Fehler");
return;
}
var es = new EventSource("/api/export/progress/" + encodeURIComponent(roomId));
es.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.type === "stream_end") {
es.close();
return;
}
addLog(data.type, data.message);
if (data.progress) {
document.getElementById("progress-fill").style.width = data.progress + "%";
}
if (data.type === "done") {
es.close();
stepDone(4);
document.getElementById("export-actions").classList.remove("hidden");
if (data.html_path) {
document.getElementById("btn-open-export").href = data.html_path;
}
if (data.zip_path) {
var zipBtn = document.getElementById("btn-download-zip");
zipBtn.href = data.zip_path;
zipBtn.classList.remove("hidden");
}
loadExports();
}
};
es.onerror = function() {
es.close();
addLog("error", "Verbindung zum Server verloren");
};
} catch (e) {
addLog("error", e.message);
}
}
function addLog(type, message) {
var box = document.getElementById("log-box");
var line = document.createElement("div");
line.className = "log-line " + type;
var prefix = {info: "[i]", progress: "[>]", warning: "[!]", error: "[x]", done: "[OK]"};
line.textContent = (prefix[type] || "[?]") + " " + message;
box.appendChild(line);
box.scrollTop = box.scrollHeight;
}
async function loadExports() {
var list = document.getElementById("exports-list");
try {
var resp = await fetch("/api/exports");
var exports = await resp.json();
if (exports.length === 0) {
list.innerHTML = '<span style="color:var(--text2);font-size:14px">Noch keine Exports vorhanden</span>';
return;
}
list.innerHTML = "";
exports.forEach(function(ex) {
var item = document.createElement("div");
item.className = "export-item";
item.innerHTML =
'<div class="info">' +
'<div class="name">' + escHtml(ex.room_name) + "</div>" +
'<div class="meta">' + ex.total_events + " Nachrichten, " + ex.total_media + " Medien &middot; " + ex.last_export + "</div>" +
"</div>" +
'<div class="actions">' +
'<a class="btn secondary" href="' + ex.html_path + '" target="_blank" style="font-size:13px;padding:6px 14px">Öffnen</a>' +
(ex.zip_path ? '<a class="btn secondary" href="' + ex.zip_path + '" download style="font-size:13px;padding:6px 14px">&#128230; ZIP</a>' : "") +
'<button class="btn danger" style="font-size:13px;padding:6px 14px" onclick="deleteExport(\'' + ex.dir_name.replace(/'/g, "\\'") + '\', \'' + escHtml(ex.room_name).replace(/'/g, "\\'") + '\')">&#128465; Löschen</button>' +
"</div>";
list.appendChild(item);
});
} catch (e) {
list.innerHTML = '<span style="color:var(--text2)">Fehler beim Laden</span>';
}
}
async function deleteExport(dirName, roomName) {
if (!confirm('Export "' + roomName + '" wirklich löschen?')) return;
try {
var resp = await fetch("/api/exports/" + encodeURIComponent(dirName), {method: "DELETE"});
if (!resp.ok) throw new Error("Löschen fehlgeschlagen");
loadExports();
} catch (e) {
alert("Fehler: " + e.message);
}
}
function escHtml(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function onEnter(ids, fn) {
ids.forEach(function(id) {
document.getElementById(id).addEventListener("keydown", function(e) {
if (e.key === "Enter") { e.preventDefault(); fn(); }
});
});
}
onEnter(["hs-url", "username", "password", "hs-url2", "access-token"], doConnect);
onEnter(["key-passphrase"], doImportKeys);
// Init
(async function() {
try {
var resp = await fetch("/api/session");
var data = await resp.json();
if (data.user_id) {
connected = true;
setStatus("connect-status", "success", "Session wiederhergestellt: " + data.user_id);
stepDone(1);
document.getElementById("btn-disconnect").classList.remove("hidden");
document.getElementById("card-keys").classList.remove("disabled");
if (data.homeserver) {
document.getElementById("hs-url").value = data.homeserver;
document.getElementById("hs-url2").value = data.homeserver;
}
}
} catch(e) {}
loadExports();
})();
</script>
</body>
</html>