Upload via GUI (85 Dateien)

This commit is contained in:
2026-10-22 09:13:21 +00:00
parent 2ad8777311
commit f4c16b173f
23 changed files with 1982 additions and 539 deletions
+4
View File
@@ -12,18 +12,22 @@ const DEFAULTS = {
// Uploads
uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster
uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen
uploadBlockSecrets: true, // Dateien mit Zugangsdaten nie hochladen (auf allen Upload-Wegen)
uploadPreview: 'always', // 'always' | 'warnings' (nur bei Auffälligkeiten anzeigen)
notifyTransfers: 'hidden', // Windows-Meldung bei fertigem/fehlgeschlagenem Upload: 'hidden' (nur wenn die App nicht vorn ist) | 'never'
// Darstellung
theme: 'dark', // 'dark' | 'light' | 'system'
zoom: 100, // Prozent
// System
autoUpdateCheck: true,
showSplash: true, // Startbildschirm beim Öffnen (nie beim versteckten Start mit Windows)
closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit'
defaultCloneDir: ''
};
const CHOICES = {
uploadPreview: ['always', 'warnings'],
notifyTransfers: ['hidden', 'never'],
theme: ['dark', 'light', 'system'],
closeAction: ['auto', 'tray', 'quit']
};
+2 -2
View File
@@ -88,7 +88,7 @@ function isPackFailure(stderr) {
Upload-Vorschau
---------------------------------------------------------------- */
async function previewUpload({ paths, destPath }, deps) {
const { items, skipped, ignored } = await deps.buildItemsFromPaths(paths, destPath);
const { items, skipped, ignored, blocked } = await deps.buildItemsFromPaths(paths, destPath);
const files = [];
let totalBytes = 0;
for (const item of items) {
@@ -109,7 +109,7 @@ async function previewUpload({ paths, destPath }, deps) {
...(ignored || [])
];
return {
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size,
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size, blocked: blocked || [],
previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan
};
}
+49
View File
@@ -47,6 +47,55 @@ release/
*.exe
*.blockmap
*.sha256
`
},
python: {
label: 'Python',
content: `# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
build/
dist/
`
},
java: {
label: 'Java (Maven/Gradle)',
content: `# Java
target/
build/
.gradle/
*.class
*.jar
*.war
*.iml
out/
`
},
php: {
label: 'PHP',
content: `# PHP
vendor/
composer.phar
.phpunit.result.cache
`
},
unity: {
label: 'Unity',
content: `# Unity
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
[Bb]uilds/
[Ll]ogs/
[Uu]ser[Ss]ettings/
*.csproj
*.sln
`
}
};
+57
View File
@@ -0,0 +1,57 @@
// Pause-Schranke für Übertragungen: Arbeitsschritte rufen vor jedem Schritt wait() auf und bleiben
// dort stehen, solange pausiert ist. Ein Abbruch (AbortSignal) löst wartende Schritte sofort mit
// dem Abbruchfehler (code UPLOAD_CANCELLED) aus.
//
// Pausiert wird zwischen den Schritten, nicht mittendrin: Ein laufender Clone, Push oder
// Paket-Upload wird erst fertig, danach hält die Übertragung an.
function cancelledError() {
const err = new Error('Upload abgebrochen');
err.code = 'UPLOAD_CANCELLED';
return err;
}
function createPauseGate({ onChange } = {}) {
let paused = false;
let waiters = [];
function setPaused(value) {
if (paused === value) return false;
paused = value;
if (!value) {
const release = waiters;
waiters = [];
for (const w of release) w.resolve();
}
if (onChange) { try { onChange(paused); } catch (_) {} }
return true;
}
function wait(signal) {
if (signal && signal.aborted) return Promise.reject(cancelledError());
if (!paused) return Promise.resolve();
return new Promise((resolve, reject) => {
const waiter = { resolve: null };
const onAbort = () => {
waiters = waiters.filter((w) => w !== waiter);
reject(cancelledError());
};
waiter.resolve = () => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve();
};
waiters.push(waiter);
if (signal) signal.addEventListener('abort', onAbort, { once: true });
});
}
return {
pause: () => setPaused(true),
resume: () => setPaused(false),
isPaused: () => paused,
waitingCount: () => waiters.length,
wait
};
}
module.exports = { createPauseGate };
+64
View File
@@ -0,0 +1,64 @@
// Hilfen für „Release für den Updater veröffentlichen“.
// Der Updater (updater.js) vergleicht den Tag des neuesten Releases mit der lokalen Version, sucht das
// Installer-Asset und liest die SHA-256-Prüfsumme aus einem Asset „<datei>.sha256“ oder aus einer Zeile
// im Release-Text, die Dateiname und 64 Hex-Zeichen enthält. Dieses Modul stellt sicher, dass beides stimmt.
const SHA256_RE = /\b([a-fA-F0-9]{64})\b/;
// '2.2.1' aus 'Git-Manager-GUI-Setup-2.2.1.exe' (oder null)
function parseVersionFromFileName(name) {
const m = String(name || '').match(/(\d+\.\d+\.\d+)/);
return m ? m[1] : null;
}
// Zerlegt 'v2.10.3' in [2, 10, 3]; ungültige Teile zählen als 0.
function versionParts(v) {
return String(v || '').replace(/^[^\d]*/, '').split('.').map((p) => parseInt(p, 10) || 0);
}
function compareVersions(a, b) {
const pa = versionParts(a);
const pb = versionParts(b);
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
// Inhalt einer .sha256-Datei ("<hash> <name>") → Hash in Kleinbuchstaben oder null
function parseSidecar(text) {
const m = String(text || '').match(SHA256_RE);
return m ? m[1].toLowerCase() : null;
}
// Prüft, ob ein neuer Tag zum Updater passt.
// releases: vorhandene Releases ({ tag_name }), neueste zuerst oder beliebig.
function checkNewTag(tag, releases) {
const wanted = String(tag || '').trim();
if (!/^v?\d+\.\d+\.\d+$/i.test(wanted)) {
return { ok: false, reason: 'Der Tag muss eine Version wie v2.2.1 sein, sonst erkennt der Updater das Release nicht.' };
}
const tags = (releases || []).map((r) => String(r.tag_name || '')).filter(Boolean);
if (tags.some((t) => t.toLowerCase() === wanted.toLowerCase())) {
return { ok: false, reason: `Den Tag „${wanted}“ gibt es schon.` };
}
const newest = tags.filter((t) => /\d+\.\d+\.\d+/.test(t)).sort(compareVersions).pop();
if (newest && compareVersions(wanted, newest) <= 0) {
return { ok: false, reason: `Die Version ${wanted} ist nicht neuer als das neueste Release ${newest}. Der Updater würde sie nicht anbieten.` };
}
return { ok: true, newest: newest || null };
}
// Hängt die Prüfsummen an die Release-Notizen, in dem Format, das der Updater liest.
function buildReleaseBody(notes, assets) {
const base = String(notes || '').replace(/\s+$/, '');
const lines = (assets || [])
.filter((a) => a && a.name && /^[a-fA-F0-9]{64}$/.test(String(a.sha256 || '')))
.map((a) => `- ${a.name}: ${String(a.sha256).toLowerCase()}`);
if (lines.length === 0) return base;
const block = ['### Prüfsummen (SHA-256)', ...lines].join('\n');
return base ? `${base}\n\n${block}\n` : `${block}\n`;
}
module.exports = { parseVersionFromFileName, compareVersions, parseSidecar, checkNewTag, buildReleaseBody };
+116
View File
@@ -0,0 +1,116 @@
// Startanimation des Hauptfensters (abschaltbar unter Einstellungen → Darstellung).
//
// Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo das Programm erscheinen wird,
// und zeigt das sich drehende Logo samt Schriftzug. Ist die Oberfläche geladen, wird das noch
// unsichtbare Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte nach außen
// aufgezogen. Am Ende übernimmt das echte Fenster an exakt derselben Stelle, so wirkt es, als baue
// sich das Programm auf.
//
// Das Startfenster wird VOR dem Hauptfenster angelegt (begin), damit das Logo so früh wie möglich
// erscheint. Danach lädt das Hauptfenster, und play() übernimmt den Rest.
const { BrowserWindow } = require('electron');
const MIN_SPIN_MS = 1600; // so lange dreht sich das Logo mindestens
const MAX_WAIT_MS = 12000; // spätestens dann wird das Hauptfenster gezeigt
const MAX_GROW_MS = 2500; // der Aufbau darf nie länger dauern, sonst bliebe das Fenster unsichtbar
const MAX_PAINT_WAIT_MS = 1500;
const IGNORE_MOUSE_DELAY_MS = 600; // Mausereignisse erst nach dem ersten Zeichnen ignorieren, sonst wird das Fenster weiß
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// Wie lange noch gewartet werden muss, damit das Logo mindestens minMs lang zu sehen war.
function remainingMs(shownAt, now, minMs) {
if (!shownAt) return 0;
return Math.max(0, minMs - (now - shownAt));
}
// Position und Größe des Hauptfensters, zentriert im Arbeitsbereich. Wird für Start- und Hauptfenster
// gleich berechnet, damit beide exakt übereinander liegen.
function centeredBounds(workArea, width, height) {
const w = Math.min(width, workArea.width);
const h = Math.min(height, workArea.height);
return {
x: Math.round(workArea.x + (workArea.width - w) / 2),
y: Math.round(workArea.y + (workArea.height - h) / 2),
width: w,
height: h
};
}
// Legt das Startfenster an und zeigt es, sobald es geladen ist.
// Liefert { window, shownAt }: shownAt ist ein Promise auf den Zeitpunkt, an dem das Logo sichtbar wurde.
function begin({ htmlPath, bounds }) {
const window = new BrowserWindow({
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false,
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
backgroundColor: '#00000000', title: 'Git Manager',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, spellcheck: false }
});
const shownAt = window.loadFile(htmlPath).then(() => {
if (window.isDestroyed()) return 0;
window.showInactive();
// Das Startfenster soll keine Mausklicks schlucken. WICHTIG: Der Aufruf muss NACH dem Zeigen und etwas
// verzögert erfolgen. Davor aufgerufen wird das durchsichtige Fenster unter Windows weiß (getestet).
setTimeout(() => { if (!window.isDestroyed()) window.setIgnoreMouseEvents(true); }, IGNORE_MOUSE_DELAY_MS);
return Date.now();
}).catch(() => 0);
return { window, shownAt };
}
// Wartet, bis die Oberfläche gezeichnet ist (Liste da oder nach höchstens MAX_PAINT_WAIT_MS),
// sonst wüchse ein halbleeres Fenster auf.
async function waitForPaintedUi(webContents) {
const check = (expression) => webContents.executeJavaScript(expression, true).catch(() => true);
const until = Date.now() + MAX_PAINT_WAIT_MS;
while (Date.now() < until) {
if (await check("document.readyState === 'complete' && (document.querySelectorAll('#explorerGrid > *').length > 0)")) break;
await wait(100);
}
await wait(200);
}
/**
* Spielt die Startanimation und zeigt am Ende das Hauptfenster.
* @param {BrowserWindow} mainWin Hauptfenster, noch nicht angezeigt, an denselben Bounds wie das Startfenster
* @param {Promise<void>} uiReady wird erfüllt, sobald die Oberfläche geladen ist
* @param {{window: BrowserWindow, shownAt: Promise<number>}} startHandle Ergebnis von begin()
*/
async function play(mainWin, uiReady, startHandle) {
const start = startHandle.window;
const showMain = () => {
if (mainWin.isDestroyed()) return;
mainWin.setOpacity(1);
mainWin.setIgnoreMouseEvents(false);
if (!mainWin.isVisible()) mainWin.show();
mainWin.focus();
};
try {
const shownAt = await startHandle.shownAt;
await Promise.race([uiReady, wait(MAX_WAIT_MS)]);
await wait(remainingMs(shownAt, Date.now(), MIN_SPIN_MS));
if (mainWin.isDestroyed()) return;
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
mainWin.setOpacity(0);
mainWin.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
mainWin.show();
await waitForPaintedUi(mainWin.webContents);
if (mainWin.isDestroyed()) return;
const image = await mainWin.webContents.capturePage();
if (!image.isEmpty() && !start.isDestroyed()) {
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(image.toDataURL())})`), wait(MAX_GROW_MS)]);
}
} catch (_) {
// Die Animation ist nur Zierde: im Fehlerfall einfach das Hauptfenster zeigen
} finally {
showMain();
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
}
}
module.exports = { begin, play, remainingMs, centeredBounds, MIN_SPIN_MS };
+22
View File
@@ -0,0 +1,22 @@
// Text der Windows-Benachrichtigung zu einem beendeten Upload (aus dem Verlaufseintrag).
// Abgebrochene Uploads melden nichts: Der Nutzer hat sie selbst gestoppt.
function pluralFiles(n) {
return `${n} Datei${n === 1 ? '' : 'en'}`;
}
function describeTransfer(entry) {
if (!entry || entry.status === 'abgebrochen') return null;
const repo = entry.repo || 'Repository';
if (entry.status === 'ok') {
const files = typeof entry.files === 'number' ? pluralFiles(entry.files) : 'Upload';
return { title: 'Upload fertig', body: `${repo}: ${files} hochgeladen.` };
}
const detail = entry.error ? ` ${String(entry.error).slice(0, 140)}` : '';
const failed = entry.failed ? ` ${pluralFiles(entry.failed)} fehlgeschlagen.` : '';
return { title: 'Upload fehlgeschlagen', body: `${repo}:${failed || detail || ' Unbekannter Fehler.'}`.trim() };
}
module.exports = { describeTransfer };
+58
View File
@@ -0,0 +1,58 @@
// Inhalt des Tray-Menüs und -Tooltips, getrennt von Electron, damit er testbar bleibt.
function trayTooltip(version, active, paused = false) {
const state = active > 0 ? (paused ? ' · Übertragung pausiert' : ' · Übertragung läuft') : '';
return `Git Manager Explorer Pro v${version}${state}`;
}
// Liefert die Menüvorlage (Einträge mit id, label, click ...).
// active: Zahl laufender Übertragungen, queued: Einträge in der Retry-Queue,
// paused: ob die Übertragung pausiert ist, autostart: ob die App mit Windows startet;
// on*: Aktionen des jeweiligen Eintrags.
function buildTrayTemplate({ version, active = 0, queued = 0, paused = false, autostart = false, onOpen, onHistory, onPause, onCancel, onAutostart, onQuit }) {
const status = [{
id: 'status',
label: active === 0 ? 'Keine laufende Übertragung'
: active === 1 ? `1 Übertragung ${paused ? 'pausiert' : 'läuft'}`
: `${active} Übertragungen ${paused ? 'pausiert' : 'laufen'}`,
enabled: false
}];
if (queued > 0) status.push({ id: 'queue', label: `Retry-Queue: ${queued} wartend`, enabled: false });
return [
{ id: 'header', label: `Git Manager Explorer Pro v${version}`, enabled: false },
...status,
{ type: 'separator' },
{ id: 'open', label: 'Fenster öffnen', click: onOpen },
{ id: 'history', label: 'Upload-Verlauf', click: onHistory },
{ id: 'pause', label: paused ? 'Übertragung fortsetzen' : 'Übertragung pausieren', enabled: active > 0, click: onPause },
{ id: 'cancel', label: 'Laufende Übertragung abbrechen', enabled: active > 0, click: onCancel },
{ type: 'separator' },
{ id: 'autostart', label: 'Mit Windows starten', type: 'checkbox', checked: !!autostart, click: onAutostart },
{ type: 'separator' },
{ id: 'quit', label: 'Beenden', click: onQuit }
];
}
// Reine Daten für das Menüfenster (ohne Funktionen, damit sie per IPC übertragen werden können).
function serializeTemplate(template) {
return template.map((item) => (item.type === 'separator'
? { type: 'separator' }
: {
id: item.id,
label: item.label,
type: item.type || 'normal',
enabled: item.enabled !== false,
checked: !!item.checked
}));
}
// Führt die Aktion eines Eintrags aus. Gibt false zurück, wenn es ihn nicht gibt oder er gesperrt ist.
function runAction(template, id) {
const item = template.find((i) => i.id === id);
if (!item || item.enabled === false || typeof item.click !== 'function') return false;
item.click(item.type === 'checkbox' ? { checked: !item.checked } : undefined);
return true;
}
module.exports = { buildTrayTemplate, serializeTemplate, runAction, trayTooltip };
+140
View File
@@ -0,0 +1,140 @@
// Eigenes Menüfenster für das Tray-Symbol.
// Das native Electron-Menü zeichnet unter Windows keine Hover-Markierung; dieses kleine Fenster
// (renderer/tray-menu.html) übernimmt Aussehen und Markierung selbst.
const { BrowserWindow, screen, nativeTheme, ipcMain } = require('electron');
const WIDTH = 286; // Fensterbreite inkl. 8 px Rand für den Schatten
const DEFAULT_HEIGHT = 320;
const MARGIN = 8;
function clamp(value, min, max) {
return Math.max(min, Math.min(value, Math.max(min, max)));
}
// Position des Menüs: am Mauszeiger, innerhalb des sichtbaren Bereichs. Liegt der Zeiger in der
// Taskleiste (unter/über dem Arbeitsbereich), sitzt das Menü direkt daneben.
function computePosition(point, workArea, width, height) {
const x = clamp(point.x - Math.round(width / 2), workArea.x + MARGIN, workArea.x + workArea.width - width - MARGIN);
let y;
if (point.y >= workArea.y + workArea.height) y = workArea.y + workArea.height - height - MARGIN;
else if (point.y <= workArea.y) y = workArea.y + MARGIN;
else y = clamp(point.y - Math.round(height / 2), workArea.y + MARGIN, workArea.y + workArea.height - height - MARGIN);
return { x, y };
}
function createTrayPopup({ htmlPath, preloadPath, getModel, onAction }) {
let win = null;
let height = DEFAULT_HEIGHT;
let shownAt = 0;
let handlersRegistered = false;
const own = (event) => win && !win.isDestroyed() && event.sender === win.webContents;
function registerHandlers() {
if (handlersRegistered) return;
handlersRegistered = true;
ipcMain.on('tray-menu:action', (event, id) => {
if (!own(event)) return;
hide();
try { onAction(String(id)); } catch (e) { console.warn('[Tray] Aktion fehlgeschlagen:', e.message); }
});
ipcMain.on('tray-menu:close', (event) => { if (own(event)) hide(); });
ipcMain.on('tray-menu:resize', (event, contentHeight) => {
if (!own(event)) return;
const h = Math.round(Number(contentHeight));
if (!(h > 40 && h < 900)) return;
height = h;
if (win.isVisible()) {
const { x, y } = computePosition(screen.getCursorScreenPoint(), screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea, WIDTH, height);
win.setBounds({ x, y, width: WIDTH, height });
} else {
win.setContentSize(WIDTH, height);
}
});
}
function ensureWindow() {
if (win && !win.isDestroyed()) return win;
registerHandlers();
win = new BrowserWindow({
width: WIDTH,
height,
show: false,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
focusable: true,
hasShadow: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
spellcheck: false
}
});
win.setAlwaysOnTop(true, 'pop-up-menu');
win.on('blur', () => { if (Date.now() - shownAt > 250) hide(); });
win.on('closed', () => { win = null; });
win.loadFile(htmlPath);
return win;
}
function push() {
if (!win || win.isDestroyed()) return;
win.webContents.send('tray-menu-state', { items: getModel(), dark: nativeTheme.shouldUseDarkColors });
}
function show() {
const w = ensureWindow();
const send = () => {
push();
const point = screen.getCursorScreenPoint();
const { x, y } = computePosition(point, screen.getDisplayNearestPoint(point).workArea, WIDTH, height);
w.setBounds({ x, y, width: WIDTH, height });
shownAt = Date.now();
w.show();
w.focus();
};
if (w.webContents.isLoading()) w.webContents.once('did-finish-load', send);
else send();
}
function hide() {
if (win && !win.isDestroyed() && win.isVisible()) win.hide();
}
function refresh() {
if (win && !win.isDestroyed() && win.isVisible()) push();
}
// Lädt das versteckte Fenster im Voraus. Der erste Start des Renderers dauert mehrere Sekunden;
// ohne Vorladen würde der erste Rechtsklick auf das Tray-Symbol entsprechend hängen.
function warmUp() {
ensureWindow();
}
function destroy() {
if (win && !win.isDestroyed()) win.destroy();
win = null;
}
return {
show,
warmUp,
hide,
refresh,
destroy,
isVisible: () => !!win && !win.isDestroyed() && win.isVisible(),
getWindow: () => (win && !win.isDestroyed() ? win : null)
};
}
module.exports = { createTrayPopup, computePosition };
+26
View File
@@ -0,0 +1,26 @@
// Feste Sperre für Dateien mit Zugangsdaten: Sie werden nie hochgeladen, egal über welchen Weg.
// Grundlage sind die Dateinamen-Regeln des Geheimnis-Scans (.env, Schlüssel, credentials.json ...).
// Die Sperre lässt sich in den Einstellungen abschalten (uploadBlockSecrets).
const path = require('path');
const { checkName } = require('./secretScan.js');
// Grund für die Sperre oder null, wenn die Datei hochgeladen werden darf.
function blockedReason(filePath, prefs) {
if (prefs && prefs.uploadBlockSecrets === false) return null;
return checkName(path.basename(String(filePath || '')));
}
// Teilt Einträge ({ localFile, ... }) in erlaubte und gesperrte auf.
function partitionBlocked(items, prefs) {
const allowed = [];
const blocked = [];
for (const item of items) {
const reason = blockedReason(item.localFile, prefs);
if (reason) blocked.push({ path: item.localFile, targetPath: item.targetPath, reason });
else allowed.push(item);
}
return { allowed, blocked };
}
module.exports = { blockedReason, partitionBlocked };
+97
View File
@@ -0,0 +1,97 @@
// Upload-Verlauf: die letzten Uploads (Datum, Repo, Dateizahl, Ergebnis) in einer JSON-Datei.
const nodeFs = require('fs');
// Baut aus dem Ergebnis eines Upload-Handlers einen Verlaufseintrag.
// `thrown` ist der Fehler, falls der Handler geworfen hat.
function buildEntry(kind, data, res, startedAt, thrown, now = Date.now()) {
const results = res && Array.isArray(res.results) ? res.results : null;
let status = 'ok';
if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler';
const error = thrown
? String(thrown.message || thrown)
: (res && !res.ok && !res.cancelled ? String(res.error || '') : '');
return {
at: new Date(now).toISOString(),
kind,
repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '',
files: results ? results.length : (res && res.singleFile ? 1 : null),
failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0,
status,
error: error.slice(0, 300),
durationMs: now - startedAt
};
}
// Filtert Einträge nach Status ('alle' | 'ok' | 'abgebrochen' | 'fehler') und Suchtext im Repo-Namen.
function filterEntries(entries, { status = 'alle', query = '' } = {}) {
const q = String(query || '').trim().toLowerCase();
return entries.filter((e) => {
if (status !== 'alle' && e.status !== status) return false;
if (q && !String(e.repo || '').toLowerCase().includes(q)) return false;
return true;
});
}
const pad2 = (n) => String(n).padStart(2, '0');
// Datum für Menschen: TT.MM.JJJJ HH:MM:SS (Ortszeit)
function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
const STATUS_LABEL = { ok: 'Erfolgreich', abgebrochen: 'Abgebrochen', fehler: 'Fehler' };
function csvCell(value) {
let s = value == null ? '' : String(value);
// Schutz vor Formel-Einschleusung in Excel: Zellen, die mit = + - @ beginnen, erhalten ein ' davor.
if (/^[=+\-@]/.test(s)) s = `'${s}`;
return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// CSV für deutsches Excel: Semikolon als Trenner, UTF-8 mit BOM, Windows-Zeilenenden.
function toCsv(entries) {
const header = ['Datum', 'Repo', 'Art', 'Dateien', 'Fehlgeschlagen', 'Ergebnis', 'Dauer (s)', 'Fehler'];
const rows = entries.map((e) => [
formatDate(e.at),
e.repo || '',
e.kind || '',
e.files == null ? '' : e.files,
e.failed || 0,
STATUS_LABEL[e.status] || e.status || '',
typeof e.durationMs === 'number' ? Math.round(e.durationMs / 100) / 10 : '',
e.error || ''
]);
return '' + [header, ...rows].map((r) => r.map(csvCell).join(';')).join('\r\n') + '\r\n';
}
function createUploadHistory({ file, max = 100, fs = nodeFs }) {
function read() {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function record(entry) {
try {
fs.writeFileSync(file, JSON.stringify([entry, ...read()].slice(0, max), null, 2), 'utf8');
return true;
} catch (e) {
console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message);
return false;
}
}
function clear() {
fs.writeFileSync(file, '[]', 'utf8');
}
return { read, record, clear };
}
module.exports = { buildEntry, filterEntries, createUploadHistory, toCsv, formatDate };
+3 -1
View File
@@ -2190,7 +2190,7 @@ function isPayloadTooLargeError(err) {
* @param {function(number):void} [onProgress] Fortschritt in Prozent
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
*/
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal }) {
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal, waitIfPaused }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
@@ -2241,6 +2241,8 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
const sendBatch = async (batch, retried = false) => {
throwIfUploadCancelled(signal);
// Pause: zwischen den Paketen anhalten (ein laufendes Paket wird noch fertig gesendet)
if (waitIfPaused) await waitIfPaused(signal);
const files = batch.map(item => {
const existingSha = shaByPath.get(item.targetPath);
const entry = {