Upload per ZIP-Tool (1/1)

This commit is contained in:
2026-09-26 00:13:27 +02:00
parent 8cc20fb068
commit dfb5849659
2 changed files with 288 additions and 114 deletions
+22 -6
View File
@@ -1,5 +1,7 @@
# ZIP → Gitea Upload # ZIP → Gitea Upload
**Live nutzen:** [https://software.obraasch.de/gitea-uploader/index.html](https://software.obraasch.de/gitea-uploader/index.html)
Lädt eine ZIP-Datei direkt aus dem Browser in ein Gitea-Repository hoch - Lädt eine ZIP-Datei direkt aus dem Browser in ein Gitea-Repository hoch -
mit Ordnerstruktur, in einem oder mehreren Commits. Läuft vollständig mit Ordnerstruktur, in einem oder mehreren Commits. Läuft vollständig
client-seitig; es wird ausschließlich mit dem selbst angegebenen client-seitig; es wird ausschließlich mit dem selbst angegebenen
@@ -9,8 +11,8 @@ Gitea-Server kommuniziert.
1. Gitea-Server-URL, Benutzername und Passwort oder Zugangstoken eingeben, 1. Gitea-Server-URL, Benutzername und Passwort oder Zugangstoken eingeben,
"Verbinden & Repos laden" klicken. "Verbinden & Repos laden" klicken.
2. Bestehendes Repo aus der Liste wählen **oder** einen neuen Namen 2. Konto/Organisation wählen, dann bestehendes Repo aus der Liste wählen
eingeben und "Repo erstellen" klicken. **oder** einen neuen Namen eingeben und "Repo erstellen" klicken.
3. ZIP-Datei auswählen. 3. ZIP-Datei auswählen.
4. Commit-Nachricht anpassen (optional) und "Jetzt hochladen" klicken. 4. Commit-Nachricht anpassen (optional) und "Jetzt hochladen" klicken.
@@ -19,11 +21,25 @@ angelegt. Über die Checkbox "Dateien im Repo löschen, die nicht mehr in
der ZIP enthalten sind" kann der Repo-Stand komplett an die ZIP angepasst der ZIP enthalten sind" kann der Repo-Stand komplett an die ZIP angepasst
werden (mit Bedacht nutzen). werden (mit Bedacht nutzen).
## Voraussetzung auf dem Gitea-Server Über das Zahnrad-Symbol oben rechts lassen sich Zugangsdaten im Browser
speichern (nur lokal, nichts wird übertragen) sowie als Datei exportieren
und auf einem anderen Gerät wieder importieren.
Damit der Browser die Gitea-API von einer anderen Domain aus aufrufen ## Voraussetzung auf dem Gitea-Server: CORS
darf, muss CORS für diese Domain aktiviert sein (Abschnitt `[cors]` in
`app.ini`). Damit der Browser die Gitea-API von einer anderen Domain aus aufrufen darf, muss CORS auf dem Gitea-Server
aktiviert sein. In `app.ini` (bei per Helper-Script installiertem Gitea: `/etc/gitea/app.ini`) folgenden
Abschnitt ergänzen (Position in der Datei ist egal, einfach ans Ende anfügen):
```ini
[cors]
ENABLED = true
ALLOW_DOMAIN = https://software.obraasch.de
ALLOW_CREDENTIALS = true
```
Danach den Gitea-Dienst neu starten (`systemctl restart gitea` bei nativer Installation, sonst der
entsprechende Container-Neustart), damit die Änderung greift.
## Lizenz ## Lizenz
+265 -107
View File
@@ -3,55 +3,94 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ZIP zu Gitea hochladen</title> <title>Gitea Upload</title>
<style> <style>
:root{ :root{
--bg:#eef0f2; --card:#ffffff; --text:#1a1a1a; --muted:#5a6472; --bg:#141517; --panel:#1c1d20; --panel-alt:#202225; --text:#e6e7ea; --muted:#8b9099;
--accent:#f07d1e; --accent-dark:#c9650f; --border:#d7dbe0; --accent:#f07d1e; --accent-dark:#c9650f; --border:#2c2e33;
--ok:#1a7f37; --err:#c0392b; --header-bg:#111214; --ok:#5fd98a; --err:#ff8a80; --header-bg:#0d0e0f;
} }
*{box-sizing:border-box;} *{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);} 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{background:var(--header-bg);border-bottom:2px solid var(--accent);padding:16px 24px;
header h1{margin:0;font-size:1.6rem;} display:flex;align-items:center;justify-content:space-between;}
header h1 .accent{color:var(--accent);} header .title{font-weight:800;font-size:1.3rem;letter-spacing:.5px;}
header p{margin:6px 0 0;color:#b8bcc2;font-size:.95rem;} header .title .accent{color:var(--accent);}
main{max-width:760px;margin:24px auto 60px;padding:0 16px;} header .subtitle{color:var(--muted);font-size:.8rem;margin-left:10px;font-weight:600;letter-spacing:.5px;}
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px 22px;margin-bottom:20px;} header .icons{display:flex;gap:8px;}
.card h2{margin:0 0 4px;font-size:1.05rem;} header .icons button{background:var(--panel);border:1px solid var(--border);color:var(--text);
.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;} width:32px;height:32px;border-radius:6px;cursor:pointer;font-size:1rem;
label{display:block;font-size:.88rem;color:var(--muted);margin:12px 0 4px;} display:flex;align-items:center;justify-content:center;padding:0;line-height:1;}
header .icons button:hover{border-color:var(--accent);color:var(--accent);}
button:focus{outline:none;}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
main{max-width:760px;margin:24px auto 40px;padding:0 16px;}
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px 22px;margin-bottom:18px;}
.card h2{margin:0 0 4px;font-size:1.02rem;}
.card .step-num{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;
border-radius:50%;background:var(--accent);color:#111;font-size:.8rem;font-weight:700;margin-right:8px;}
label{display:block;font-size:.85rem;color:var(--muted);margin:12px 0 4px;}
input[type=text],input[type=password],input[type=url],select{ 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; width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:6px;font-size:.93rem;
background:var(--panel-alt);color:var(--text);
} }
input[type=checkbox]{margin-right:6px;} input::placeholder{color:#5b5f66;}
input[type=checkbox]{margin-right:6px;accent-color:var(--accent);}
.checkline{display:flex;align-items:center;font-size:.85rem;color:var(--muted);margin-top:10px;}
.row{display:flex;gap:12px;flex-wrap:wrap;} .row{display:flex;gap:12px;flex-wrap:wrap;}
.row > *{flex:1;min-width:160px;} .row > *{flex:1;min-width:150px;}
button{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:10px 16px;font-size:.95rem;cursor:pointer;margin-top:14px;} button{background:var(--accent);color:#111;border:none;border-radius:6px;padding:9px 15px;
button:hover{background:var(--accent-dark);} font-size:.9rem;font-weight:600;cursor:pointer;margin-top:14px;}
button.secondary{background:#fff;color:var(--text);border:1px solid var(--border);} button:hover{background:var(--accent-dark);color:#fff;}
button.secondary:hover{background:#f3f4f5;} button.secondary{background:var(--panel-alt);color:var(--text);border:1px solid var(--border);}
button:disabled{background:#c9ccd1;cursor:not-allowed;} button.secondary:hover{border-color:var(--accent);color:var(--accent);background:var(--panel-alt);}
.hint{font-size:.82rem;color:var(--muted);margin-top:6px;} button.small{padding:6px 12px;font-size:.82rem;margin-top:0;}
.status{margin-top:14px;font-size:.9rem;} button:disabled{background:#3a3c40;color:#787c82;cursor:not-allowed;}
.hint{font-size:.78rem;color:var(--muted);margin-top:6px;}
.status{margin-top:12px;font-size:.87rem;}
.status.ok{color:var(--ok);} .status.ok{color:var(--ok);}
.status.err{color:var(--err);} .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{margin-top:14px;background:#0d0e0f;color:#c7cad0;border-radius:8px;padding:12px;
#log .l-ok{color:#7ed99a;} font-family:ui-monospace,Consolas,monospace;font-size:.8rem;max-height:200px;overflow-y:auto;
#log .l-err{color:#ff8a80;} white-space:pre-wrap;display:none;border:1px solid var(--border);}
.progress-wrap{margin-top:10px;height:8px;border-radius:4px;background:#e2e4e7;overflow:hidden;display:none;} #log .l-ok{color:var(--ok);}
#log .l-err{color:var(--err);}
.progress-wrap{margin-top:10px;height:7px;border-radius:4px;background:#2c2e33;overflow:hidden;display:none;}
.progress-bar{height:100%;background:var(--accent);width:0%;transition:width .2s;} .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{border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:10px;}
fieldset legend{font-size:.85rem;color:var(--muted);padding:0 6px;} fieldset legend{font-size:.8rem;color:var(--muted);padding:0 6px;}
.disabled-section{opacity:.5;pointer-events:none;} .disabled-section{opacity:.45;pointer-events:none;}
footer{text-align:center;color:var(--muted);font-size:.8rem;padding:20px;} details{margin-top:10px;font-size:.85rem;color:var(--muted);}
details summary{cursor:pointer;color:var(--text);}
#fileList{max-height:150px;overflow-y:auto;margin-top:8px;font-family:ui-monospace,Consolas,monospace;
font-size:.78rem;background:var(--panel-alt);border-radius:6px;padding:8px;border:1px solid var(--border);}
footer{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;color:var(--muted);
font-size:.8rem;padding:18px 24px;border-top:1px solid var(--border);}
footer a{color:var(--muted);text-decoration:none;margin-right:16px;}
footer a:hover{color:var(--accent);}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;
justify-content:center;z-index:50;}
.overlay.open{display:flex;}
.modal{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:22px 24px;
max-width:480px;width:90%;max-height:80vh;overflow-y:auto;}
.modal h3{margin-top:0;}
.modal .close{float:right;background:none;border:none;color:var(--muted);font-size:1.2rem;
cursor:pointer;margin:0;padding:0;}
.modal ol,.modal ul{padding-left:20px;font-size:.9rem;color:var(--text);}
.modal li{margin-bottom:8px;}
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>ZIP <span class="accent">→</span> Gitea</h1> <div>
<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> <span class="title">GITEA <span class="accent">UPLOAD</span></span>
<span class="subtitle">ZIP HOCHLADEN</span>
</div>
<div class="icons">
<button id="btnHelp" title="Anleitung">?</button>
<button id="btnSettings" title="Einstellungen">⚙</button>
</div>
</header> </header>
<main> <main>
@@ -59,7 +98,7 @@
<div class="card"> <div class="card">
<h2><span class="step-num">1</span>Gitea-Zugang</h2> <h2><span class="step-num">1</span>Gitea-Zugang</h2>
<label for="serverUrl">Gitea-Server</label> <label for="serverUrl">Gitea-Server</label>
<input type="url" id="serverUrl" value="https://git.obraasch.de" placeholder="https://git.example.de"> <input type="url" id="serverUrl" placeholder="https://git.beispiel.de">
<div class="row"> <div class="row">
<div> <div>
@@ -71,23 +110,26 @@
<input type="password" id="password" autocomplete="current-password"> <input type="password" id="password" autocomplete="current-password">
</div> </div>
</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> <p class="hint">Ein Zugangstoken (Einstellungen → Anwendungen → Neues Token in Gitea) ist sicherer als das echte Passwort.</p>
<button id="btnLoadRepos">Verbinden &amp; Repos laden</button> <button id="btnLoadRepos">Verbinden &amp; Repos laden</button>
<div id="authStatus" class="status"></div> <div id="authStatus" class="status"></div>
</div> </div>
<div class="card disabled-section" id="repoCard"> <div class="card disabled-section" id="repoCard">
<h2><span class="step-num">2</span>Repository wählen</h2> <h2><span class="step-num">2</span>Ziel wählen</h2>
<label for="ownerSelect">Konto / Organisation</label>
<select id="ownerSelect"><option value="">– bitte zuerst verbinden –</option></select>
<label for="repoSelect">Vorhandenes Repo</label> <label for="repoSelect">Vorhandenes Repo</label>
<select id="repoSelect"><option value="">– bitte zuerst verbinden –</option></select> <select id="repoSelect"><option value="">– bitte zuerst verbinden –</option></select>
<fieldset style="margin-top:16px;"> <fieldset>
<legend>Oder neues Repo anlegen</legend> <legend>Oder neues Repo anlegen</legend>
<div class="row"> <div class="row">
<input type="text" id="newRepoName" placeholder="repo-name"> <input type="text" id="newRepoName" placeholder="repo-name">
<label style="display:flex;align-items:center;flex:0 0 auto;margin-top:0;"> <label class="checkline" style="flex:0 0 auto;">
<input type="checkbox" id="newRepoPrivate" checked> Privat <input type="checkbox" id="newRepoPrivate" checked> Privat
</label> </label>
</div> </div>
@@ -99,20 +141,13 @@
<div class="card disabled-section" id="zipCard"> <div class="card disabled-section" id="zipCard">
<h2><span class="step-num">3</span>ZIP-Datei wählen</h2> <h2><span class="step-num">3</span>ZIP-Datei wählen</h2>
<input type="file" id="zipFile" accept=".zip"> <input type="file" id="zipFile" accept=".zip">
<p class="hint"> <label class="checkline"><input type="checkbox" id="stripRoot" checked> Obersten Ordner aus der ZIP entfernen</label>
<label style="display:inline;margin:0;"> <label class="checkline"><input type="checkbox" id="removeMissing"> Dateien im Repo löschen, die nicht mehr in der ZIP enthalten sind</label>
<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 id="zipStatus" class="status"></div>
<details id="fileListDetails" style="display:none;">
<summary>Enthaltene Dateien anzeigen</summary>
<div id="fileList"></div>
</details>
</div> </div>
<div class="card disabled-section" id="uploadCard"> <div class="card disabled-section" id="uploadCard">
@@ -128,16 +163,63 @@
</main> </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> <footer>
<div>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
<a href="./LICENSE">Lizenzen &amp; Quellcode</a>
<a href="#" id="footerAnleitung">Anleitung</a>
</div>
<div>Alle Daten bleiben in deinem Browser · MIT-Lizenz</div>
</footer>
<!-- Anleitung -->
<div class="overlay" id="helpOverlay">
<div class="modal">
<button class="close" data-close="helpOverlay">×</button>
<h3>Anleitung</h3>
<ol>
<li>Gitea-Server-URL, Benutzername und Passwort oder Zugangstoken eingeben, dann "Verbinden &amp; Repos laden".</li>
<li>Konto/Organisation wählen, dann ein vorhandenes Repo auswählen <strong>oder</strong> einen neuen Namen eingeben und "Repo erstellen".</li>
<li>ZIP-Datei auswählen. Enthält sie einen einzelnen umschließenden Ordner, wird der auf Wunsch automatisch entfernt.</li>
<li>Commit-Nachricht anpassen (optional) und "Jetzt hochladen" klicken.</li>
</ol>
<p>Dateien mit gleichem Pfad wie im Repo werden aktualisiert statt doppelt angelegt. Über
"Zugangsdaten in diesem Browser speichern" (Einstellungen, Zahnrad oben rechts) müssen Server,
Benutzername und Passwort nicht jedes Mal neu eingegeben werden.</p>
</div>
</div>
<!-- Einstellungen -->
<div class="overlay" id="settingsOverlay">
<div class="modal">
<button class="close" data-close="settingsOverlay">×</button>
<h3>Einstellungen</h3>
<label class="checkline"><input type="checkbox" id="rememberCreds"> Zugangsdaten in diesem Browser speichern</label>
<p class="hint">Werden nur lokal in diesem Browser gespeichert, nirgendwo sonst übertragen. Auf einem
gemeinsam genutzten Rechner besser deaktiviert lassen.</p>
<button class="secondary small" id="btnClearCreds">Gespeicherte Zugangsdaten löschen</button>
<hr style="border-color:var(--border);margin:18px 0;">
<p class="hint" style="margin-top:0;">Zugangsdaten als Datei sichern oder auf einem anderen Gerät wieder einlesen:</p>
<button class="secondary small" id="btnExport">Export</button>
<button class="secondary small" id="btnImportTrigger">Import</button>
<input type="file" id="importFile" accept=".json" style="display:none;">
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script> <script>
(function(){ (function(){
"use strict"; "use strict";
var STORAGE_KEY = "giteaUploaderCreds";
var state = { var state = {
server: "", authHeader: "", repos: [], repo: null, // {owner, name, defaultBranch} server: "", authHeader: "", repos: [], owners: [], selectedOwner: "",
zipFiles: null // [{path, base64}] repo: null, // {owner, name, defaultBranch}
zipFiles: null
}; };
function el(id){ return document.getElementById(id); } function el(id){ return document.getElementById(id); }
@@ -148,12 +230,8 @@
} }
function enable(id){ el(id).classList.remove("disabled-section"); } function enable(id){ el(id).classList.remove("disabled-section"); }
function disable(id){ el(id).classList.add("disabled-section"); } function disable(id){ el(id).classList.add("disabled-section"); }
function b64utf8(str){ function b64utf8(str){ return btoa(unescape(encodeURIComponent(str))); }
return btoa(unescape(encodeURIComponent(str))); function apiUrl(path){ return state.server.replace(/\/+$/,"") + path; }
}
function apiUrl(path){
return state.server.replace(/\/+$/,"") + path;
}
function logLine(msg, isErr){ function logLine(msg, isErr){
var log = el("log"); var log = el("log");
log.style.display = "block"; log.style.display = "block";
@@ -163,6 +241,18 @@
log.appendChild(line); log.appendChild(line);
log.scrollTop = log.scrollHeight; log.scrollTop = log.scrollHeight;
} }
function openOverlay(id){ el(id).classList.add("open"); }
function closeOverlay(id){ el(id).classList.remove("open"); }
document.querySelectorAll("[data-close]").forEach(function(btn){
btn.addEventListener("click", function(){ closeOverlay(btn.getAttribute("data-close")); });
});
document.querySelectorAll(".overlay").forEach(function(ov){
ov.addEventListener("click", function(e){ if(e.target === ov) ov.classList.remove("open"); });
});
el("btnHelp").addEventListener("click", function(){ openOverlay("helpOverlay"); });
el("footerAnleitung").addEventListener("click", function(e){ e.preventDefault(); openOverlay("helpOverlay"); });
el("btnSettings").addEventListener("click", function(){ openOverlay("settingsOverlay"); });
async function apiFetch(path, opts){ async function apiFetch(path, opts){
opts = opts || {}; opts = opts || {};
@@ -173,16 +263,78 @@
var res = await fetch(apiUrl(path), opts); var res = await fetch(apiUrl(path), opts);
if(!res.ok){ if(!res.ok){
var msg = "HTTP " + res.status; var msg = "HTTP " + res.status;
try{ try{ var body = await res.json(); if(body && body.message) msg += ": " + body.message; }catch(e){}
var body = await res.json();
if(body && body.message) msg += ": " + body.message;
}catch(e){}
throw new Error(msg); throw new Error(msg);
} }
if(res.status === 204) return null; if(res.status === 204) return null;
return res.json(); return res.json();
} }
// ---- Zugangsdaten merken ----
function loadStoredCreds(){
try{
var raw = localStorage.getItem(STORAGE_KEY);
if(!raw) return;
var data = JSON.parse(raw);
el("serverUrl").value = data.server || "";
el("username").value = data.username || "";
el("password").value = data.password || "";
el("rememberCreds").checked = true;
}catch(e){}
}
function saveCredsIfWanted(){
if(el("rememberCreds").checked){
localStorage.setItem(STORAGE_KEY, JSON.stringify({
server: el("serverUrl").value.trim(),
username: el("username").value.trim(),
password: el("password").value
}));
}else{
localStorage.removeItem(STORAGE_KEY);
}
}
el("btnClearCreds").addEventListener("click", function(){
localStorage.removeItem(STORAGE_KEY);
el("rememberCreds").checked = false;
el("password").value = "";
setStatus("authStatus", "Gespeicherte Zugangsdaten gelöscht.", "ok");
});
loadStoredCreds();
// ---- Export / Import ----
el("btnExport").addEventListener("click", function(){
var data = {
server: el("serverUrl").value.trim(),
username: el("username").value.trim(),
password: el("password").value
};
var blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "gitea-uploader-zugangsdaten.json";
a.click();
URL.revokeObjectURL(a.href);
});
el("btnImportTrigger").addEventListener("click", function(){ el("importFile").click(); });
el("importFile").addEventListener("change", function(){
var file = this.files[0];
if(!file) return;
var reader = new FileReader();
reader.onload = function(){
try{
var data = JSON.parse(reader.result);
el("serverUrl").value = data.server || "";
el("username").value = data.username || "";
el("password").value = data.password || "";
closeOverlay("settingsOverlay");
setStatus("authStatus", "Zugangsdaten importiert. Jetzt verbinden.", "ok");
}catch(e){
alert("Datei konnte nicht gelesen werden: " + e.message);
}
};
reader.readAsText(file);
});
// ---- Schritt 1: Verbinden & Repos laden ---- // ---- Schritt 1: Verbinden & Repos laden ----
el("btnLoadRepos").addEventListener("click", async function(){ el("btnLoadRepos").addEventListener("click", async function(){
state.server = el("serverUrl").value.trim(); state.server = el("serverUrl").value.trim();
@@ -202,16 +354,19 @@
if(batch.length < 50) break; if(batch.length < 50) break;
} }
state.repos = all; state.repos = all;
var sel = el("repoSelect");
sel.innerHTML = ""; var orgs = [];
if(all.length === 0){ try{ orgs = await apiFetch("/api/v1/user/orgs?limit=50"); }catch(e){}
sel.innerHTML = "<option value=''>– keine Repos gefunden –</option>"; state.owners = [user].concat(orgs.map(function(o){ return o.username || o.name; }));
}else{
sel.innerHTML = "<option value=''>– bitte auswählen –</option>" + var ownerSel = el("ownerSelect");
all.map(function(r, i){ ownerSel.innerHTML = state.owners.map(function(o){
return "<option value='" + i + "'>" + r.full_name + (r.private ? " (privat)" : "") + "</option>"; return "<option value='" + o + "'>" + o + (o === user ? " (persönlich)" : "") + "</option>";
}).join(""); }).join("");
} state.selectedOwner = state.owners[0];
refreshRepoOptions();
saveCredsIfWanted();
enable("repoCard"); enable("repoCard");
setStatus("authStatus", "Verbunden. " + all.length + " Repo(s) gefunden.", "ok"); setStatus("authStatus", "Verbunden. " + all.length + " Repo(s) gefunden.", "ok");
}catch(e){ }catch(e){
@@ -219,6 +374,27 @@
} }
}); });
function refreshRepoOptions(){
var sel = el("repoSelect");
var filtered = state.repos.filter(function(r){ return r.owner.login === state.selectedOwner; });
if(filtered.length === 0){
sel.innerHTML = "<option value=''>– keine Repos in diesem Konto –</option>";
}else{
sel.innerHTML = "<option value=''>– bitte auswählen –</option>" +
filtered.map(function(r){
var idx = state.repos.indexOf(r);
return "<option value='" + idx + "'>" + r.name + (r.private ? " (privat)" : "") + "</option>";
}).join("");
}
state.repo = null;
disable("zipCard");
}
el("ownerSelect").addEventListener("change", function(){
state.selectedOwner = this.value;
refreshRepoOptions();
});
el("repoSelect").addEventListener("change", function(){ el("repoSelect").addEventListener("change", function(){
var i = this.value; var i = this.value;
if(i === ""){ state.repo = null; disable("zipCard"); return; } if(i === ""){ state.repo = null; disable("zipCard"); return; }
@@ -235,16 +411,15 @@
setStatus("repoStatus", "Erstelle Repo...", ""); setStatus("repoStatus", "Erstelle Repo...", "");
try{ try{
var priv = el("newRepoPrivate").checked; var priv = el("newRepoPrivate").checked;
var r = await apiFetch("/api/v1/user/repos", { var isPersonal = state.selectedOwner === el("username").value.trim();
var path = isPersonal ? "/api/v1/user/repos" : "/api/v1/orgs/" + state.selectedOwner + "/repos";
var r = await apiFetch(path, {
method: "POST", method: "POST",
body: JSON.stringify({ name: name, private: priv, auto_init: true }) body: JSON.stringify({ name: name, private: priv, auto_init: true })
}); });
state.repos.push(r); state.repos.push(r);
refreshRepoOptions();
var sel = el("repoSelect"); 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.value = state.repos.length - 1;
sel.dispatchEvent(new Event("change")); sel.dispatchEvent(new Event("change"));
setStatus("repoStatus", "Repo '" + r.full_name + "' erstellt und ausgewählt.", "ok"); setStatus("repoStatus", "Repo '" + r.full_name + "' erstellt und ausgewählt.", "ok");
@@ -272,22 +447,21 @@
setStatus("zipStatus", "ZIP enthält keine Dateien.", "err"); setStatus("zipStatus", "ZIP enthält keine Dateien.", "err");
return; return;
} }
// gemeinsamen obersten Ordner erkennen
if(el("stripRoot").checked){ if(el("stripRoot").checked){
var firstSlash = entries[0].path.indexOf("/"); var firstSlash = entries[0].path.indexOf("/");
if(firstSlash > 0){ if(firstSlash > 0){
var prefix = entries[0].path.substring(0, firstSlash + 1); var prefix = entries[0].path.substring(0, firstSlash + 1);
var allShare = entries.every(function(e){ return e.path.indexOf(prefix) === 0; }); var allShare = entries.every(function(e){ return e.path.indexOf(prefix) === 0; });
if(allShare){ if(allShare){
entries = entries.map(function(e){ entries = entries.map(function(e){ return { path: e.path.substring(prefix.length), base64: e.base64 }; });
return { path: e.path.substring(prefix.length), base64: e.base64 };
});
} }
} }
} }
state.zipFiles = entries; state.zipFiles = entries;
enable("uploadCard"); enable("uploadCard");
setStatus("zipStatus", entries.length + " Datei(en) gefunden, bereit zum Hochladen.", "ok"); setStatus("zipStatus", entries.length + " Datei(en) gefunden, bereit zum Hochladen.", "ok");
el("fileList").textContent = entries.map(function(e){ return e.path; }).join("\n");
el("fileListDetails").style.display = "block";
}catch(e){ }catch(e){
setStatus("zipStatus", "ZIP konnte nicht gelesen werden: " + e.message, "err"); setStatus("zipStatus", "ZIP konnte nicht gelesen werden: " + e.message, "err");
} }
@@ -307,16 +481,13 @@
setStatus("uploadStatus", "Ermittle aktuellen Stand im Repo...", ""); setStatus("uploadStatus", "Ermittle aktuellen Stand im Repo...", "");
try{ try{
// aktuellen Baum holen (welche Dateien gibt es schon, mit welcher SHA)
var existing = {}; var existing = {};
try{ try{
var tree = await apiFetch( var tree = await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/api/v1/repos/" + state.repo.owner + "/" + state.repo.name +
"/git/trees/" + encodeURIComponent(state.repo.defaultBranch) + "?recursive=true" "/git/trees/" + encodeURIComponent(state.repo.defaultBranch) + "?recursive=true"
); );
(tree.tree || []).forEach(function(t){ (tree.tree || []).forEach(function(t){ if(t.type === "blob") existing[t.path] = t.sha; });
if(t.type === "blob") existing[t.path] = t.sha;
});
}catch(e){ }catch(e){
logLine("Hinweis: Konnte bestehenden Stand nicht lesen (evtl. leeres Repo) - " + e.message, false); logLine("Hinweis: Konnte bestehenden Stand nicht lesen (evtl. leeres Repo) - " + e.message, false);
} }
@@ -325,35 +496,26 @@
var operations = state.zipFiles.map(function(f){ var operations = state.zipFiles.map(function(f){
newPaths[f.path] = true; newPaths[f.path] = true;
var op = { path: f.path, content: f.base64 }; var op = { path: f.path, content: f.base64 };
if(existing.hasOwnProperty(f.path)){ if(existing.hasOwnProperty(f.path)){ op.operation = "update"; op.sha = existing[f.path]; }
op.operation = "update"; else{ op.operation = "create"; }
op.sha = existing[f.path];
}else{
op.operation = "create";
}
return op; return op;
}); });
if(el("removeMissing").checked){ if(el("removeMissing").checked){
Object.keys(existing).forEach(function(p){ Object.keys(existing).forEach(function(p){
if(!newPaths[p]){ if(!newPaths[p]) operations.push({ path: p, operation: "delete", sha: existing[p] });
operations.push({ path: p, operation: "delete", sha: existing[p] });
}
}); });
} }
// in Batches aufteilen (Größe & Anzahl begrenzen, wegen Server-Limits)
var batches = []; var batches = [];
var current = []; var current = [];
var currentSize = 0; var currentSize = 0;
var MAX_FILES = 20; var MAX_FILES = 20;
var MAX_BYTES = 3 * 1024 * 1024; // ~3MB Base64 pro Anfrage var MAX_BYTES = 3 * 1024 * 1024;
operations.forEach(function(op){ operations.forEach(function(op){
var size = op.content ? op.content.length : 0; var size = op.content ? op.content.length : 0;
if(current.length >= MAX_FILES || (currentSize + size) > MAX_BYTES){ if(current.length >= MAX_FILES || (currentSize + size) > MAX_BYTES){
batches.push(current); batches.push(current); current = []; currentSize = 0;
current = [];
currentSize = 0;
} }
current.push(op); current.push(op);
currentSize += size; currentSize += size;
@@ -367,14 +529,10 @@
setStatus("uploadStatus", "Lade Batch " + (b+1) + " von " + batches.length + " hoch...", ""); setStatus("uploadStatus", "Lade Batch " + (b+1) + " von " + batches.length + " hoch...", "");
await apiFetch( await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/contents", "/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/contents",
{ { method: "POST", body: JSON.stringify({
method: "POST", files: batches[b], message: msg + " (" + (b+1) + "/" + batches.length + ")",
body: JSON.stringify({
files: batches[b],
message: msg + " (" + (b+1) + "/" + batches.length + ")",
branch: state.repo.defaultBranch branch: state.repo.defaultBranch
}) }) }
}
); );
done += batches[b].length; done += batches[b].length;
el("progressBar").style.width = Math.round(done / total * 100) + "%"; el("progressBar").style.width = Math.round(done / total * 100) + "%";