Files
gitea-uploader/index.html
T

397 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ZIP zu Gitea hochladen</title>
<style>
:root{
--bg:#eef0f2; --card:#ffffff; --text:#1a1a1a; --muted:#5a6472;
--accent:#f07d1e; --accent-dark:#c9650f; --border:#d7dbe0;
--ok:#1a7f37; --err:#c0392b; --header-bg:#111214;
}
*{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);}
header{background:var(--header-bg);color:#fff;padding:22px 24px;}
header h1{margin:0;font-size:1.6rem;}
header h1 .accent{color:var(--accent);}
header p{margin:6px 0 0;color:#b8bcc2;font-size:.95rem;}
main{max-width:760px;margin:24px auto 60px;padding:0 16px;}
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px 22px;margin-bottom:20px;}
.card h2{margin:0 0 4px;font-size:1.05rem;}
.card .step-num{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--accent);color:#fff;font-size:.85rem;margin-right:8px;}
label{display:block;font-size:.88rem;color:var(--muted);margin:12px 0 4px;}
input[type=text],input[type=password],input[type=url],select{
width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:6px;font-size:.95rem;background:#fbfbfc;
}
input[type=checkbox]{margin-right:6px;}
.row{display:flex;gap:12px;flex-wrap:wrap;}
.row > *{flex:1;min-width:160px;}
button{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:10px 16px;font-size:.95rem;cursor:pointer;margin-top:14px;}
button:hover{background:var(--accent-dark);}
button.secondary{background:#fff;color:var(--text);border:1px solid var(--border);}
button.secondary:hover{background:#f3f4f5;}
button:disabled{background:#c9ccd1;cursor:not-allowed;}
.hint{font-size:.82rem;color:var(--muted);margin-top:6px;}
.status{margin-top:14px;font-size:.9rem;}
.status.ok{color:var(--ok);}
.status.err{color:var(--err);}
#log{margin-top:14px;background:#0e0f11;color:#d7dbe0;border-radius:8px;padding:12px;font-family:ui-monospace,Consolas,monospace;font-size:.82rem;max-height:220px;overflow-y:auto;white-space:pre-wrap;display:none;}
#log .l-ok{color:#7ed99a;}
#log .l-err{color:#ff8a80;}
.progress-wrap{margin-top:10px;height:8px;border-radius:4px;background:#e2e4e7;overflow:hidden;display:none;}
.progress-bar{height:100%;background:var(--accent);width:0%;transition:width .2s;}
fieldset{border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:8px;}
fieldset legend{font-size:.85rem;color:var(--muted);padding:0 6px;}
.disabled-section{opacity:.5;pointer-events:none;}
footer{text-align:center;color:var(--muted);font-size:.8rem;padding:20px;}
</style>
</head>
<body>
<header>
<h1>ZIP <span class="accent">→</span> Gitea</h1>
<p>Lädt eine ZIP-Datei direkt aus deinem Browser in ein Gitea-Repository hoch. Nichts verlässt deinen Rechner außer der Verbindung zu deinem eigenen Gitea-Server.</p>
</header>
<main>
<div class="card">
<h2><span class="step-num">1</span>Gitea-Zugang</h2>
<label for="serverUrl">Gitea-Server</label>
<input type="url" id="serverUrl" value="https://git.obraasch.de" placeholder="https://git.example.de">
<div class="row">
<div>
<label for="username">Benutzername</label>
<input type="text" id="username" autocomplete="username">
</div>
<div>
<label for="password">Passwort oder Zugangstoken</label>
<input type="password" id="password" autocomplete="current-password">
</div>
</div>
<p class="hint">Ein Zugangstoken (Einstellungen → Anwendungen → Neues Token in Gitea) ist sicherer als das echte Passwort, weil du es jederzeit einzeln widerrufen kannst. Beides funktioniert hier.</p>
<button id="btnLoadRepos">Verbinden &amp; Repos laden</button>
<div id="authStatus" class="status"></div>
</div>
<div class="card disabled-section" id="repoCard">
<h2><span class="step-num">2</span>Repository wählen</h2>
<label for="repoSelect">Vorhandenes Repo</label>
<select id="repoSelect"><option value="">– bitte zuerst verbinden –</option></select>
<fieldset style="margin-top:16px;">
<legend>Oder neues Repo anlegen</legend>
<div class="row">
<input type="text" id="newRepoName" placeholder="repo-name">
<label style="display:flex;align-items:center;flex:0 0 auto;margin-top:0;">
<input type="checkbox" id="newRepoPrivate" checked> Privat
</label>
</div>
<button class="secondary" id="btnCreateRepo">Repo erstellen &amp; auswählen</button>
</fieldset>
<div id="repoStatus" class="status"></div>
</div>
<div class="card disabled-section" id="zipCard">
<h2><span class="step-num">3</span>ZIP-Datei wählen</h2>
<input type="file" id="zipFile" accept=".zip">
<p class="hint">
<label style="display:inline;margin:0;">
<input type="checkbox" id="stripRoot" checked> Obersten Ordner aus der ZIP entfernen
</label>
– meistens gewünscht, wenn die ZIP einen einzelnen Projektordner enthält (z.B. "meinprojekt/...").
</p>
<p class="hint">
<label style="display:inline;margin:0;">
<input type="checkbox" id="removeMissing">
Dateien im Repo löschen, die nicht mehr in der ZIP enthalten sind
</label>
– nur aktivieren, wenn die ZIP wirklich den kompletten, aktuellen Stand enthält!
</p>
<div id="zipStatus" class="status"></div>
</div>
<div class="card disabled-section" id="uploadCard">
<h2><span class="step-num">4</span>Hochladen</h2>
<label for="commitMsg">Commit-Nachricht</label>
<input type="text" id="commitMsg" value="Upload per ZIP-Tool">
<button id="btnUpload">Jetzt hochladen</button>
<div class="progress-wrap" id="progressWrap"><div class="progress-bar" id="progressBar"></div></div>
<div id="uploadStatus" class="status"></div>
<div id="log"></div>
</div>
</main>
<footer>Läuft vollständig in deinem Browser. Es werden keine Daten an irgendeinen Server außer dem oben angegebenen Gitea-Server übertragen.</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
(function(){
"use strict";
var state = {
server: "", authHeader: "", repos: [], repo: null, // {owner, name, defaultBranch}
zipFiles: null // [{path, base64}]
};
function el(id){ return document.getElementById(id); }
function setStatus(id, msg, cls){
var n = el(id);
n.textContent = msg;
n.className = "status" + (cls ? " " + cls : "");
}
function enable(id){ el(id).classList.remove("disabled-section"); }
function disable(id){ el(id).classList.add("disabled-section"); }
function b64utf8(str){
return btoa(unescape(encodeURIComponent(str)));
}
function apiUrl(path){
return state.server.replace(/\/+$/,"") + path;
}
function logLine(msg, isErr){
var log = el("log");
log.style.display = "block";
var line = document.createElement("div");
line.className = isErr ? "l-err" : "l-ok";
line.textContent = msg;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
async function apiFetch(path, opts){
opts = opts || {};
opts.headers = Object.assign({
"Authorization": state.authHeader,
"Content-Type": "application/json"
}, opts.headers || {});
var res = await fetch(apiUrl(path), opts);
if(!res.ok){
var msg = "HTTP " + res.status;
try{
var body = await res.json();
if(body && body.message) msg += ": " + body.message;
}catch(e){}
throw new Error(msg);
}
if(res.status === 204) return null;
return res.json();
}
// ---- Schritt 1: Verbinden & Repos laden ----
el("btnLoadRepos").addEventListener("click", async function(){
state.server = el("serverUrl").value.trim();
var user = el("username").value.trim();
var pass = el("password").value;
if(!state.server || !user || !pass){
setStatus("authStatus", "Bitte Server, Benutzername und Passwort/Token ausfüllen.", "err");
return;
}
state.authHeader = "Basic " + b64utf8(user + ":" + pass);
setStatus("authStatus", "Verbinde...", "");
try{
var all = [];
for(var page = 1; page <= 5; page++){
var batch = await apiFetch("/api/v1/user/repos?limit=50&page=" + page);
all = all.concat(batch);
if(batch.length < 50) break;
}
state.repos = all;
var sel = el("repoSelect");
sel.innerHTML = "";
if(all.length === 0){
sel.innerHTML = "<option value=''>– keine Repos gefunden –</option>";
}else{
sel.innerHTML = "<option value=''>– bitte auswählen –</option>" +
all.map(function(r, i){
return "<option value='" + i + "'>" + r.full_name + (r.private ? " (privat)" : "") + "</option>";
}).join("");
}
enable("repoCard");
setStatus("authStatus", "Verbunden. " + all.length + " Repo(s) gefunden.", "ok");
}catch(e){
setStatus("authStatus", "Login fehlgeschlagen: " + e.message, "err");
}
});
el("repoSelect").addEventListener("change", function(){
var i = this.value;
if(i === ""){ state.repo = null; disable("zipCard"); return; }
var r = state.repos[i];
state.repo = { owner: r.owner.login, name: r.name, defaultBranch: r.default_branch || "main" };
setStatus("repoStatus", "Ausgewählt: " + r.full_name + " (Branch: " + state.repo.defaultBranch + ")", "ok");
enable("zipCard");
});
// ---- Schritt 2: Neues Repo erstellen ----
el("btnCreateRepo").addEventListener("click", async function(){
var name = el("newRepoName").value.trim();
if(!name){ setStatus("repoStatus", "Bitte einen Repo-Namen eingeben.", "err"); return; }
setStatus("repoStatus", "Erstelle Repo...", "");
try{
var priv = el("newRepoPrivate").checked;
var r = await apiFetch("/api/v1/user/repos", {
method: "POST",
body: JSON.stringify({ name: name, private: priv, auto_init: true })
});
state.repos.push(r);
var sel = el("repoSelect");
var opt = document.createElement("option");
opt.value = state.repos.length - 1;
opt.textContent = r.full_name + (r.private ? " (privat)" : "");
sel.appendChild(opt);
sel.value = state.repos.length - 1;
sel.dispatchEvent(new Event("change"));
setStatus("repoStatus", "Repo '" + r.full_name + "' erstellt und ausgewählt.", "ok");
}catch(e){
setStatus("repoStatus", "Erstellen fehlgeschlagen: " + e.message, "err");
}
});
// ---- Schritt 3: ZIP einlesen ----
el("zipFile").addEventListener("change", async function(){
var file = this.files[0];
if(!file) return;
setStatus("zipStatus", "Lese ZIP...", "");
try{
var zip = await JSZip.loadAsync(file);
var entries = [];
var names = Object.keys(zip.files);
for(var i = 0; i < names.length; i++){
var entry = zip.files[names[i]];
if(entry.dir) continue;
var b64 = await entry.async("base64");
entries.push({ path: entry.name, base64: b64 });
}
if(entries.length === 0){
setStatus("zipStatus", "ZIP enthält keine Dateien.", "err");
return;
}
// gemeinsamen obersten Ordner erkennen
if(el("stripRoot").checked){
var firstSlash = entries[0].path.indexOf("/");
if(firstSlash > 0){
var prefix = entries[0].path.substring(0, firstSlash + 1);
var allShare = entries.every(function(e){ return e.path.indexOf(prefix) === 0; });
if(allShare){
entries = entries.map(function(e){
return { path: e.path.substring(prefix.length), base64: e.base64 };
});
}
}
}
state.zipFiles = entries;
enable("uploadCard");
setStatus("zipStatus", entries.length + " Datei(en) gefunden, bereit zum Hochladen.", "ok");
}catch(e){
setStatus("zipStatus", "ZIP konnte nicht gelesen werden: " + e.message, "err");
}
});
// ---- Schritt 4: Hochladen ----
el("btnUpload").addEventListener("click", async function(){
if(!state.repo || !state.zipFiles){
setStatus("uploadStatus", "Bitte erst Repo wählen und ZIP einlesen.", "err");
return;
}
var btn = el("btnUpload");
btn.disabled = true;
el("log").innerHTML = "";
el("log").style.display = "block";
el("progressWrap").style.display = "block";
setStatus("uploadStatus", "Ermittle aktuellen Stand im Repo...", "");
try{
// aktuellen Baum holen (welche Dateien gibt es schon, mit welcher SHA)
var existing = {};
try{
var tree = await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name +
"/git/trees/" + encodeURIComponent(state.repo.defaultBranch) + "?recursive=true"
);
(tree.tree || []).forEach(function(t){
if(t.type === "blob") existing[t.path] = t.sha;
});
}catch(e){
logLine("Hinweis: Konnte bestehenden Stand nicht lesen (evtl. leeres Repo) - " + e.message, false);
}
var newPaths = {};
var operations = state.zipFiles.map(function(f){
newPaths[f.path] = true;
var op = { path: f.path, content: f.base64 };
if(existing.hasOwnProperty(f.path)){
op.operation = "update";
op.sha = existing[f.path];
}else{
op.operation = "create";
}
return op;
});
if(el("removeMissing").checked){
Object.keys(existing).forEach(function(p){
if(!newPaths[p]){
operations.push({ path: p, operation: "delete", sha: existing[p] });
}
});
}
// in Batches aufteilen (Größe & Anzahl begrenzen, wegen Server-Limits)
var batches = [];
var current = [];
var currentSize = 0;
var MAX_FILES = 20;
var MAX_BYTES = 3 * 1024 * 1024; // ~3MB Base64 pro Anfrage
operations.forEach(function(op){
var size = op.content ? op.content.length : 0;
if(current.length >= MAX_FILES || (currentSize + size) > MAX_BYTES){
batches.push(current);
current = [];
currentSize = 0;
}
current.push(op);
currentSize += size;
});
if(current.length) batches.push(current);
var msg = el("commitMsg").value.trim() || "Upload per ZIP-Tool";
var done = 0, total = operations.length;
for(var b = 0; b < batches.length; b++){
setStatus("uploadStatus", "Lade Batch " + (b+1) + " von " + batches.length + " hoch...", "");
await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/contents",
{
method: "POST",
body: JSON.stringify({
files: batches[b],
message: msg + " (" + (b+1) + "/" + batches.length + ")",
branch: state.repo.defaultBranch
})
}
);
done += batches[b].length;
el("progressBar").style.width = Math.round(done / total * 100) + "%";
logLine("Batch " + (b+1) + "/" + batches.length + " ok (" + batches[b].length + " Datei(en))", false);
}
setStatus("uploadStatus", "Fertig! " + total + " Datei(en) hochgeladen/aktualisiert.", "ok");
}catch(e){
setStatus("uploadStatus", "Fehler: " + e.message, "err");
logLine("Abgebrochen: " + e.message, true);
}finally{
btn.disabled = false;
}
});
})();
</script>
</body>
</html>