feat: add automation control center UI

Preserve monotonic source-cleanup requirements while excluding ambiguous job identities from Main payloads, and reconcile mixed add responses only across globally unique jobs that were actually sent.

Render the automation status card, localized fixed-column metrics, read-only test overlay, queue-limit and reconciliation controls, and persistent finish-and-pause or resume action from immutable automation snapshots.

Add hidden Electron coverage for cleanup siblings, Main fingerprints, German and English states, keyboard focus, Escape and inert behavior, responsive 760-pixel layouts, and snapshot-gated upload controls.
This commit is contained in:
Sucukdeluxe
2026-08-26 13:20:40 +02:00
parent 28a475401e
commit 325a20d3fb
5 changed files with 1372 additions and 111 deletions
+457 -46
View File
@@ -38,6 +38,8 @@ function refreshLocalizedRuntimeUi() {
const historyContainer = document.getElementById('historyContainer');
if (historyContainer && historyRowsData.length) renderHistoryTable(historyContainer);
updateStatusBar();
refreshAutomationControlCenter();
refreshAutomationTestOverlay();
const activeRecentTab = document.querySelector('.recent-tab.active');
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
@@ -64,6 +66,13 @@ let hosterSettings = {};
let uploading = false;
let healthCheckRunning = false;
let automationRuntimeStatus = Object.freeze({});
let automationRuntimeStatusAvailable = false;
let automationRuntimeStartedAt = null;
let automationPauseResumeBusy = false;
let automationTestGeneration = 0;
let automationTestReturnFocus = null;
let automationTestInertState = [];
let automationTestViewState = Object.freeze({ loading: false, summary: null, error: '' });
let managedOnlineBackups = [];
let managedOnlineBackupsAuthoritative = false;
let managedOnlineBackupMutationGeneration = 0;
@@ -472,7 +481,25 @@ function automationSettings() {
}
function applyAutomationRuntimeStatus(value) {
automationRuntimeStatus = freezeAutomationValue({ ...(value || {}) });
const next = { ...(value || {}) };
const incomingStartedAt = automationTimestamp(next.startedAt);
const wasRunning = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true;
if (next.running === true && next.paused !== true) {
automationRuntimeStartedAt = incomingStartedAt || (wasRunning ? automationRuntimeStartedAt : null) || Date.now();
} else if (incomingStartedAt) {
automationRuntimeStartedAt = incomingStartedAt;
}
if (automationRuntimeStartedAt) next.startedAt = automationRuntimeStartedAt;
if (typeof next.paused === 'boolean' && config.globalSettings) {
const folderMonitor = config.globalSettings.folderMonitor || {};
config.globalSettings.folderMonitor = {
...folderMonitor,
paused: next.paused,
pausedAt: next.paused ? (next.pausedAt ?? folderMonitor.pausedAt ?? Date.now()) : null
};
}
automationRuntimeStatus = freezeAutomationValue(next);
automationRuntimeStatusAvailable = true;
return automationRuntimeStatus;
}
@@ -496,6 +523,12 @@ function createAutomationStatusSnapshot() {
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
const availableSlots = normalized.queueLimitJobs === 0 ? null : Math.max(0, normalized.queueLimitJobs - currentJobCount);
const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry);
const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || '');
const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt;
const lastReconcileAt = automationTimestamp(automationRuntimeStatus.lastScanAt);
const nextReconcileAt = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true && lastReconcileAt
? lastReconcileAt + normalized.reconcileIntervalMinutes * 60000
: null;
const snapshot = {
...automationRuntimeStatus,
enabled: folderSettings.enabled === true,
@@ -506,7 +539,13 @@ function createAutomationStatusSnapshot() {
currentJobCount,
availableSlots,
queueLimited: normalized.queueLimitJobs !== 0 && availableSlots === 0,
telemetry
telemetry,
error,
startedAt,
lastReconcileAt,
nextReconcileAt,
lastErrorAt: automationTimestamp(automationRuntimeStatus.lastErrorAt) || automationTimestamp(telemetry.lastErrorAt),
statusAvailable: automationRuntimeStatusAvailable
};
snapshot.state = window.AutomationControl.deriveAutomationState(snapshot);
return freezeAutomationValue(snapshot);
@@ -741,7 +780,7 @@ async function applyAutomationEvaluation(evaluation) {
persistQueueStateSoon(true);
return freezeAutomationValue({ ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false });
}
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
applyAddJobsFingerprints(addRequest, result?.sourceCleanupFingerprints);
} catch {
restoreSourceCleanupStates(cleanupPreparation.rollbackStates);
return freezeAutomationValue({ ok: false, error: 'Jobs konnten nicht hinzugefügt werden.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false });
@@ -775,9 +814,331 @@ async function applyAutomationEvaluation(evaluation) {
async function runFolderMonitorTestScan() {
const result = await window.api.folderMonitorTestScan();
if (result?.error) throw new Error('Ordnerüberwachung konnte nicht getestet werden.');
return evaluateAutomationCandidates(result?.files || [], { dryRun: true, trigger: result?.trigger || 'test' });
}
const automationStatePresentation = Object.freeze({
inactive: Object.freeze({ label: 'Inaktiv', className: 'state-inactive' }),
active: Object.freeze({ label: 'Aktiv', className: 'state-active' }),
paused: Object.freeze({ label: 'Pausiert', className: 'state-paused' }),
'queue-limited': Object.freeze({ label: 'Queue-Limit erreicht', className: 'state-queue-limited' }),
disconnected: Object.freeze({ label: 'Ordner getrennt', className: 'state-disconnected' }),
error: Object.freeze({ label: 'Fehler', className: 'state-error' })
});
const automationTestMetricDefinitions = Object.freeze([
Object.freeze(['found', 'Gefundene Dateien']),
Object.freeze(['filterMatched', 'Passend zum Dateifilter']),
Object.freeze(['alreadyProcessed', 'Bereits verarbeitet']),
Object.freeze(['unavailable', 'Fehlend, leer oder nicht lesbar']),
Object.freeze(['sizeLimitedJobs', 'Durch Größenlimits ausgeschlossen']),
Object.freeze(['acceptedFiles', 'Akzeptierte Dateien']),
Object.freeze(['selectedTargets', 'Ausgewählte Ziele']),
Object.freeze(['resultingJobs', 'Entstehende Upload-Jobs']),
Object.freeze(['availableSlots', 'Verfügbare Jobs bis zum Queue-Limit']),
Object.freeze(['deferredFiles', 'Aktuell zurückzustellende Dateien'])
]);
function automationTimestamp(value) {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : null;
}
function formatAutomationNumber(value) {
const numeric = Number(value);
return new Intl.NumberFormat(getUiLocale(), { maximumFractionDigits: 0 }).format(Number.isFinite(numeric) ? Math.max(0, numeric) : 0);
}
function formatAutomationDateTime(value) {
const timestamp = automationTimestamp(value);
if (!timestamp) return localizeUiText('Nie');
return new Intl.DateTimeFormat(getUiLocale(), { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(timestamp));
}
function setAutomationText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function renderAutomationStatusSnapshot(snapshot) {
const card = document.getElementById('automationStatusCard');
if (!card || !snapshot) return snapshot;
const presentation = automationStatePresentation[snapshot.state] || automationStatePresentation.inactive;
const badge = document.getElementById('automationStateBadge');
if (badge) {
badge.className = `automation-state-badge ${presentation.className}`;
badge.textContent = localizeUiText(presentation.label);
}
card.dataset.state = snapshot.state;
const queueLimit = snapshot.queueLimitJobs === 0
? localizeUiText('Unbegrenzt')
: formatAutomationNumber(snapshot.queueLimitJobs);
const queueText = `${formatAutomationNumber(snapshot.currentJobCount)} / ${queueLimit}`;
setAutomationText('automationQueueMeter', queueText);
const queueBar = document.getElementById('automationQueueMeterBar');
if (queueBar) {
const percentage = snapshot.queueLimitJobs === 0
? 0
: Math.min(100, Math.max(0, snapshot.currentJobCount / Math.max(1, snapshot.queueLimitJobs) * 100));
queueBar.style.width = `${percentage}%`;
}
const queueTrack = document.getElementById('automationQueueMeterTrack');
if (queueTrack) {
queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount));
if (snapshot.queueLimitJobs === 0) queueTrack.removeAttribute('aria-valuemax');
else queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs));
queueTrack.setAttribute('aria-valuetext', queueText);
}
const telemetry = snapshot.telemetry || {};
setAutomationText('automationMonitoringSince', snapshot.running === true ? formatAutomationDateTime(snapshot.startedAt) : localizeUiText('Nie'));
setAutomationText('automationFolderReachable', snapshot.reachable === null || snapshot.reachable === undefined ? '—' : localizeUiText(snapshot.reachable ? 'Ja' : 'Nein'));
setAutomationText('automationLastDetectedFile', telemetry.lastDetectedName || localizeUiText('Keine Datei erkannt'));
setAutomationText('automationDetectedToday', formatAutomationNumber(telemetry.detected));
setAutomationText('automationQueuedToday', formatAutomationNumber(telemetry.queued));
setAutomationText('automationSkippedToday', formatAutomationNumber(telemetry.skipped));
setAutomationText('automationDeferredToday', formatAutomationNumber(telemetry.deferred));
setAutomationText('automationLastReconcile', formatAutomationDateTime(snapshot.lastReconcileAt));
setAutomationText('automationNextReconcile', formatAutomationDateTime(snapshot.nextReconcileAt));
const errorRow = document.getElementById('automationLastErrorRow');
const errorText = String(snapshot.error || telemetry.lastError || '');
if (errorRow) errorRow.hidden = errorText.length === 0;
setAutomationText('automationLastError', errorText);
return snapshot;
}
function ensureAutomationPauseResumeButton() {
const existing = document.getElementById('automationPauseResumeBtn') || document.getElementById('finishStopBtn');
if (!existing) return null;
existing.id = 'automationPauseResumeBtn';
existing.classList.add('automation-pause-resume-btn');
let label = existing.querySelector('.automation-pause-resume-label');
if (!label) {
label = document.createElement('span');
label.className = 'automation-pause-resume-label';
existing.appendChild(label);
}
return existing;
}
function syncAutomationPauseResumeButton(snapshot) {
const button = ensureAutomationPauseResumeButton();
if (!button || !snapshot) return;
const label = snapshot.paused === true ? 'Fortsetzen' : 'Abschließen und pausieren';
const localized = localizeUiText(label);
const text = button.querySelector('.automation-pause-resume-label');
if (text) text.textContent = localized;
button.title = localized;
button.setAttribute('aria-label', localized);
button.classList.toggle('automation-resume', snapshot.paused === true);
button.disabled = automationPauseResumeBusy;
}
function syncAutomationContextStartControls(blocked) {
document.querySelectorAll('[data-action="start-selected"], [data-action="retry-selected"]').forEach(element => {
element.setAttribute('aria-disabled', String(blocked));
element.classList.toggle('ctx-item-disabled', blocked);
});
}
function refreshAutomationControlCenter() {
const snapshot = createAutomationStatusSnapshot();
updateQueueActionButtons(snapshot);
return snapshot;
}
function setAutomationTestBackgroundInert(active) {
const overlay = document.getElementById('automationTestOverlay');
if (!overlay) return;
if (active) {
if (automationTestInertState.length > 0) return;
automationTestInertState = Array.from(document.body.children)
.filter(element => element !== overlay && 'inert' in element)
.map(element => ({ element, inert: element.inert }));
automationTestInertState.forEach(({ element }) => { element.inert = true; });
return;
}
automationTestInertState.forEach(({ element, inert }) => {
if (element.isConnected) element.inert = inert;
});
automationTestInertState = [];
}
function ensureAutomationTestOverlay() {
let overlay = document.getElementById('automationTestOverlay');
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = 'automationTestOverlay';
overlay.className = 'modal-overlay automation-test-overlay';
overlay.style.display = 'none';
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('aria-busy', 'false');
overlay.innerHTML = `
<section class="modal-card automation-test-modal" role="dialog" aria-modal="true" aria-labelledby="automationTestTitle" aria-describedby="automationTestDescription" tabindex="-1">
<header class="modal-header automation-test-header">
<div>
<h3 id="automationTestTitle">Test der Ordnerüberwachung</h3>
<p id="automationTestDescription">Der Test verändert weder Queue noch Einstellungen.</p>
</div>
</header>
<div class="modal-body automation-test-body">
<div class="automation-test-loading" id="automationTestSpinner" hidden>
<span class="automation-test-spinner" aria-hidden="true"></span>
<span>Ordner wird geprüft</span>
</div>
<div class="automation-test-metrics" id="automationTestMetrics">
${automationTestMetricDefinitions.map(([key, label]) => `
<div class="automation-test-metric" data-automation-test-metric="${key}">
<span class="automation-test-label">${label}</span>
<strong class="automation-test-value">0</strong>
</div>`).join('')}
</div>
<p class="automation-test-error" id="automationTestError" role="alert" hidden></p>
</div>
<footer class="modal-footer automation-test-footer">
<button class="btn btn-secondary" id="automationTestCloseBtn" type="button">Schließen</button>
</footer>
</section>`;
document.body.appendChild(overlay);
document.getElementById('automationTestCloseBtn')?.addEventListener('click', closeAutomationTestOverlay);
overlay.addEventListener('click', event => {
if (event.target === overlay) closeAutomationTestOverlay();
});
document.addEventListener('keydown', handleAutomationTestKeydown, true);
return overlay;
}
function renderAutomationTestViewState(state = automationTestViewState) {
const overlay = ensureAutomationTestOverlay();
automationTestViewState = freezeAutomationValue({
loading: state.loading === true,
summary: state.summary || null,
error: String(state.error || '')
});
overlay.setAttribute('aria-busy', automationTestViewState.loading ? 'true' : 'false');
const spinner = document.getElementById('automationTestSpinner');
const metrics = document.getElementById('automationTestMetrics');
const error = document.getElementById('automationTestError');
if (spinner) spinner.hidden = !automationTestViewState.loading;
if (metrics) metrics.hidden = automationTestViewState.loading;
if (error) {
error.hidden = automationTestViewState.error.length === 0;
error.textContent = automationTestViewState.error ? localizeUiText(automationTestViewState.error) : '';
}
setAutomationText('automationTestTitle', localizeUiText('Test der Ordnerüberwachung'));
setAutomationText('automationTestDescription', localizeUiText('Der Test verändert weder Queue noch Einstellungen.'));
setAutomationText('automationTestCloseBtn', localizeUiText('Schließen'));
for (const [key, label] of automationTestMetricDefinitions) {
const row = document.querySelector(`[data-automation-test-metric="${key}"]`);
const labelElement = row?.querySelector('.automation-test-label');
const valueElement = row?.querySelector('.automation-test-value');
if (labelElement) labelElement.textContent = localizeUiText(label);
if (valueElement) {
const value = automationTestViewState.summary?.[key];
valueElement.textContent = key === 'availableSlots' && value === null
? localizeUiText('Unbegrenzt')
: formatAutomationNumber(value);
}
}
const button = document.getElementById('automationTestBtn');
if (button) button.disabled = automationTestViewState.loading;
return automationTestViewState;
}
function refreshAutomationTestOverlay() {
if (!document.getElementById('automationTestOverlay')) return automationTestViewState;
return renderAutomationTestViewState(automationTestViewState);
}
function openAutomationTestOverlay() {
const overlay = ensureAutomationTestOverlay();
automationTestReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.style.display = 'flex';
overlay.setAttribute('aria-hidden', 'false');
setAutomationTestBackgroundInert(true);
document.getElementById('automationTestCloseBtn')?.focus();
return overlay;
}
function closeAutomationTestOverlay() {
const overlay = document.getElementById('automationTestOverlay');
if (!overlay || overlay.style.display === 'none') return;
const canceledLoading = automationTestViewState.loading === true;
automationTestGeneration++;
overlay.style.display = 'none';
overlay.setAttribute('aria-hidden', 'true');
if (canceledLoading) renderAutomationTestViewState({ loading: false, summary: null, error: '' });
setAutomationTestBackgroundInert(false);
const returnFocus = automationTestReturnFocus;
automationTestReturnFocus = null;
if (returnFocus?.isConnected) returnFocus.focus();
}
function handleAutomationTestKeydown(event) {
const overlay = document.getElementById('automationTestOverlay');
if (!overlay || overlay.style.display !== 'flex') return;
const dialog = overlay.querySelector('[role="dialog"]');
const closeButton = document.getElementById('automationTestCloseBtn');
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
closeAutomationTestOverlay();
return;
}
if (event.key === 'Tab') {
event.preventDefault();
event.stopImmediatePropagation();
closeButton?.focus();
return;
}
if (dialog && !dialog.contains(event.target)) {
event.preventDefault();
event.stopImmediatePropagation();
closeButton?.focus();
}
}
async function runAutomationTestOverlay() {
openAutomationTestOverlay();
const generation = ++automationTestGeneration;
renderAutomationTestViewState({ loading: true, summary: null, error: '' });
try {
const evaluation = await runFolderMonitorTestScan();
if (generation !== automationTestGeneration) return;
renderAutomationTestViewState({ loading: false, summary: evaluation.summary, error: '' });
} catch {
if (generation !== automationTestGeneration) return;
renderAutomationTestViewState({ loading: false, summary: null, error: 'Ordnerüberwachung konnte nicht getestet werden.' });
}
}
async function toggleAutomationPauseResume() {
if (automationPauseResumeBusy) return;
const snapshot = createAutomationStatusSnapshot();
const resume = snapshot.paused === true;
automationPauseResumeBusy = true;
updateQueueActionButtons(snapshot);
try {
const result = resume
? await window.api.automationResume()
: await window.api.automationPauseAfterActive();
if (result?.error) throw new Error(result.error);
applyAutomationRuntimeStatus({ ...result, paused: resume ? false : true });
if (!resume && uploading) {
lastUploadStats.state = 'stopping';
updateStatusBar();
}
} catch {
showCopyToast(resume ? 'Automatik konnte nicht fortgesetzt werden.' : 'Automatik konnte nicht pausiert werden.');
} finally {
automationPauseResumeBusy = false;
refreshAutomationControlCenter();
}
}
window.evaluateAutomationCandidates = evaluateAutomationCandidates;
window.applyAutomationEvaluation = applyAutomationEvaluation;
window.createAutomationStatusSnapshot = createAutomationStatusSnapshot;
@@ -900,7 +1261,7 @@ async function init() {
if (typeof window.api.onAutomationStatus === 'function') {
window.api.onAutomationStatus(status => {
applyAutomationRuntimeStatus(status);
updateQueueActionButtons();
refreshAutomationControlCenter();
});
}
@@ -1543,7 +1904,7 @@ async function applyHosterSelection() {
cleanupStates: cleanupPreparation.rollbackStates
});
if (!outcome.consistent) regularInjectionFailure = 'Jobs konnten nicht eindeutig bestätigt werden.';
else if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
else applyAddJobsFingerprints(addRequest, result?.sourceCleanupFingerprints);
}
} catch {
regularInjectionFailure = 'Jobs konnten nicht hinzugefügt werden.';
@@ -2038,19 +2399,21 @@ function updateUploadView(options = {}) {
updateQueueActionButtons();
}
function updateStartButton() {
function updateStartButton(snapshot = createAutomationStatusSnapshot()) {
const btn = document.getElementById('startUploadBtn');
if (!btn) return;
const hosters = getSelectedHosters();
const hasQueuedJobs = queueJobs.some(isStartableQueueJob);
const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0;
btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
const startBlocked = snapshot.paused === true || snapshot.statusAvailable !== true;
btn.disabled = startBlocked || uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
}
const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']);
const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
function updateQueueActionButtons() {
updateStartButton();
function updateQueueActionButtons(snapshot = createAutomationStatusSnapshot()) {
updateStartButton(snapshot);
_normalizeQueueSelectionToVisible();
const hasSelection = selectedJobIds.size > 0;
@@ -2072,22 +2435,27 @@ function updateQueueActionButtons() {
const startSelectedBtn = document.getElementById('startSelectedBtn');
const reuploadBtn = document.getElementById('reuploadSelectedBtn');
const abortSelectedBtn = document.getElementById('abortSelectedBtn');
const finishStopBtn = document.getElementById('finishStopBtn');
ensureAutomationPauseResumeButton();
const abortAllBtn = document.getElementById('abortAllBtn');
const moveTopBtn = document.getElementById('moveTopBtn');
const moveUpBtn = document.getElementById('moveUpBtn');
const moveDownBtn = document.getElementById('moveDownBtn');
const moveBottomBtn = document.getElementById('moveBottomBtn');
if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection;
if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection;
const startBlocked = snapshot.paused === true || snapshot.statusAvailable !== true;
if (startSelectedBtn) startSelectedBtn.disabled = startBlocked || uploading || !hasStartableSelection;
if (reuploadBtn) reuploadBtn.disabled = startBlocked || !hasUploadSelection;
if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection;
if (finishStopBtn) finishStopBtn.disabled = !uploading;
syncAutomationPauseResumeButton(snapshot);
if (abortAllBtn) abortAllBtn.disabled = !uploading;
if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection;
if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection;
if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection;
if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection;
const retryFailedBtn = document.getElementById('retryFailedBtn');
if (retryFailedBtn) retryFailedBtn.disabled = startBlocked || !queueJobs.some(job => job.status === 'error');
syncAutomationContextStartControls(startBlocked);
renderAutomationStatusSnapshot(snapshot);
syncDataActionState();
}
@@ -3628,6 +3996,10 @@ document.getElementById('contextMenu').addEventListener('click', (e) => {
async function handleContextAction(action, targetJobId = null) {
_normalizeQueueSelectionToVisible();
if (['start-selected', 'retry-selected'].includes(action)) {
const snapshot = createAutomationStatusSnapshot();
if (snapshot.paused === true || snapshot.statusAvailable !== true) return;
}
if (action === 'start-selected') {
startSelectedUpload();
} else if (action === 'copy-links') {
@@ -3824,10 +4196,7 @@ function analyzeAddJobsInput(jobs, relevantJobs = jobs) {
function prepareAddSourceCleanup(analysis) {
const rollbackStates = captureSourceCleanupStates();
if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [], rollbackStates };
const cleanupJobs = analysis.confirmableJobs.map(job => ({
...job,
sourceCleanupRequiredHosters: []
}));
const cleanupJobs = analysis.confirmableJobs.map(job => ({ ...job }));
const cleanupJobsById = new Map(cleanupJobs.map(job => [job.id, job]));
const prepared = window.SourceCleanupPolicy.prepareGroups(
cleanupJobs,
@@ -3892,7 +4261,7 @@ function resolveAddJobsOutcome(jobs, result, analysis = null) {
const added = Number(result?.added);
const confirmationConsistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length;
return {
consistent: unconfirmableJobs.length === 0 && confirmationConsistent,
consistent: confirmationConsistent,
added: Number.isInteger(added) && added >= 0 ? added : 0,
addedJobs: confirmationConsistent ? remainingJobs : [],
alreadyJobs: [...alreadyIds].map(id => jobsById.get(id)),
@@ -3919,6 +4288,11 @@ function applyAddJobsOutcome(jobs, result, options = {}) {
return outcome;
}
function applyAddJobsFingerprints(analysis, fingerprints) {
if (!fingerprints || !window.SourceCleanupPolicy) return [];
return window.SourceCleanupPolicy.applyFingerprints(analysis?.confirmableJobs || [], fingerprints);
}
async function startUpload(opts) {
if (uploading) return { ok: false, error: 'Upload läuft bereits.' };
if (await isAutomationPaused()) return { ok: false, error: 'Automatik ist pausiert' };
@@ -4086,9 +4460,7 @@ async function startSelectedUpload(explicitJobs) {
showCopyToast(error);
return { ok: false, error };
}
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
}
applyAddJobsFingerprints(addRequest, result?.sourceCleanupFingerprints);
persistQueueStateSoon();
const added = outcome.added;
// Use ASCII-only toast text here to avoid encoding artifacts on some systems.
@@ -4804,13 +5176,6 @@ async function abortSelectedJobs() {
persistQueueStateSoon(true);
}
async function finishUploadsInProgress() {
if (!uploading) return;
await window.api.finishAfterActive();
lastUploadStats.state = 'stopping';
updateStatusBar();
}
async function abortAllUploads() {
if (!uploading) return;
if (!await showAppConfirm({ title: 'Alle Uploads abbrechen?', message: 'Alle laufenden Uploads werden abgebrochen und in die Warteschlange zurückgesetzt.', confirmText: 'Alle abbrechen', danger: true })) return;
@@ -5563,6 +5928,7 @@ function renderSettings() {
const globalSettings = config.globalSettings || {};
const configuredAccounts = getAvailableHosters();
const fm = globalSettings.folderMonitor || {};
const normalizedFm = window.AutomationControl.normalizeAutomationSettings(fm);
const remoteSettings = globalSettings.remote || {};
const pageDefinitions = [
@@ -5715,6 +6081,33 @@ function renderSettings() {
pages.automatik.innerHTML = `
${pageHeader('Automatik', 'Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.')}
<section class="automation-status-card" id="automationStatusCard" aria-live="polite">
<div class="automation-status-header">
<span class="automation-state-badge state-inactive" id="automationStateBadge">Inaktiv</span>
<div class="automation-queue-summary">
<span class="automation-queue-label">Aktuelle Queue-Auslastung</span>
<strong class="automation-status-value automation-queue-value" id="automationQueueMeter">0 / 15.000</strong>
<div class="automation-queue-track" id="automationQueueMeterTrack" role="progressbar" aria-label="Aktuelle Queue-Auslastung" aria-valuemin="0" aria-valuenow="0" aria-valuemax="15000">
<span class="automation-queue-bar" id="automationQueueMeterBar"></span>
</div>
</div>
</div>
<div class="automation-status-metrics">
<div class="automation-status-metric"><span>Überwachung läuft seit</span><strong class="automation-status-value" id="automationMonitoringSince">Nie</strong></div>
<div class="automation-status-metric"><span>Ordner erreichbar</span><strong class="automation-status-value" id="automationFolderReachable"></strong></div>
<div class="automation-status-metric"><span>Letzte erkannte Datei</span><strong class="automation-status-value" id="automationLastDetectedFile">Keine Datei erkannt</strong></div>
<div class="automation-status-metric"><span>Heute erkannt</span><strong class="automation-status-value" id="automationDetectedToday">0</strong></div>
<div class="automation-status-metric"><span>Heute eingereiht</span><strong class="automation-status-value" id="automationQueuedToday">0</strong></div>
<div class="automation-status-metric"><span>Heute übersprungen</span><strong class="automation-status-value" id="automationSkippedToday">0</strong></div>
<div class="automation-status-metric"><span>Wegen Queue-Limit zurückgestellt</span><strong class="automation-status-value" id="automationDeferredToday">0</strong></div>
<div class="automation-status-metric"><span>Letzter Abgleich</span><strong class="automation-status-value" id="automationLastReconcile">Nie</strong></div>
<div class="automation-status-metric"><span>Nächster Abgleich</span><strong class="automation-status-value" id="automationNextReconcile">Nie</strong></div>
</div>
<div class="automation-status-error" id="automationLastErrorRow" hidden>
<span>Letzter Fehler</span>
<strong id="automationLastError"></strong>
</div>
</section>
<div class="settings-section-label">Unbeaufsichtigter Betrieb</div>
<div class="settings-row automation-retry-row">
<label for="autoRetryRoundsInput">Automatische Wiederholungsrunden</label>
@@ -5730,12 +6123,30 @@ function renderSettings() {
<span class="hint">Minuten · jede weitere Runde wartet entsprechend länger</span>
</div>
</div>
<div class="settings-section-label">Ordnerüberwachung <span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span></div>
<div class="settings-section-label">Ordnerüberwachung</div>
<div class="settings-row">
<label>Ordnerpfad</label>
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen..." style="flex:1">
<button class="btn btn-xs btn-secondary" id="fmChooseFolderBtn">Wählen</button>
</div>
<div class="settings-row automation-capacity-row">
<label for="fmQueueLimitInput">Maximale automatische Queue-Größe</label>
<input type="number" class="hs-input settings-autosave" id="fmQueueLimitInput" value="${normalizedFm.queueLimitJobs}" min="0" step="1">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-row automation-interval-row">
<label for="fmReconcileIntervalInput">Abgleichintervall</label>
<select class="hs-input settings-autosave" id="fmReconcileIntervalInput">
${[1, 5, 15, 30, 60].map(value => `<option value="${value}" ${normalizedFm.reconcileIntervalMinutes === value ? 'selected' : ''}>${value === 1 ? '1 Minute' : `${value} Minuten`}</option>`).join('')}
</select>
</div>
<div class="settings-row automation-test-action-row">
<div class="automation-test-action-copy">
<strong>Ordnerüberwachung testen</strong>
<span class="hint">Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.</span>
</div>
<button class="btn btn-secondary" id="automationTestBtn" type="button">Ordnerüberwachung testen</button>
</div>
<div class="settings-row">
<label>Dateierweiterungen</label>
<select class="hs-input settings-autosave" id="fmFilterModeInput" style="width:auto;margin-right:6px">
@@ -6240,25 +6651,16 @@ function renderSettings() {
});
}
const updateFmBadge = () => {
const b = document.getElementById('folderMonitorStatusBadge');
if (!b) return;
const enabled = document.getElementById('fmEnabledInput')?.checked;
const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim();
if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; }
else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; }
};
document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge);
document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge);
document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => {
const folder = await window.api.folderMonitorSelectFolder();
if (folder) {
document.getElementById('fmFolderPathInput').value = folder;
updateFmBadge();
scheduleSettingsSave();
}
});
document.getElementById('automationTestBtn')?.addEventListener('click', runAutomationTestOverlay);
ensureAutomationTestOverlay();
refreshAutomationControlCenter();
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
const token = document.getElementById('remoteTokenInput').value;
@@ -6549,6 +6951,11 @@ async function performSaveSettings(options = {}) {
const n = parseInt(el.value || String(dflt), 10) || dflt;
return Math.max(lo, Math.min(hi, n));
};
const normalizedAutomationInputs = window.AutomationControl.normalizeAutomationSettings({
...curFm,
queueLimitJobs: document.getElementById('fmQueueLimitInput')?.value ?? curFm.queueLimitJobs,
reconcileIntervalMinutes: document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes
});
const globalSettings = {
...cur,
@@ -6591,6 +6998,8 @@ async function performSaveSettings(options = {}) {
skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false),
delaySec: elInt('fmDelaySecInput', curFm.delaySec ?? 3, 3, 1, 300),
autoStart: elChk('fmAutoStartInput', curFm.autoStart !== false),
queueLimitJobs: normalizedAutomationInputs.queueLimitJobs,
reconcileIntervalMinutes: normalizedAutomationInputs.reconcileIntervalMinutes,
hosters: document.querySelector('.fm-hoster-checkbox')
? Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
: (curFm.hosters || [])
@@ -6658,8 +7067,8 @@ async function performSaveSettings(options = {}) {
// Start/stop folder monitor based on settings
const fmSettings = globalSettings.folderMonitor;
const badge = document.getElementById('folderMonitorStatusBadge');
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
if (fmSettings.paused !== true) {
try {
const folderStart = await window.api.folderMonitorStart(fmSettings);
if (folderStart?.includesExisting) {
@@ -6669,14 +7078,15 @@ async function performSaveSettings(options = {}) {
const includeExistingInput = document.getElementById('fmIncludeExistingInput');
if (includeExistingInput) includeExistingInput.checked = false;
}
if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; }
} catch {
if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; }
applyAutomationRuntimeStatus({ ...automationRuntimeStatus, error: 'Ordnerüberwachung fehlgeschlagen' });
}
}
} else {
await window.api.folderMonitorStop();
if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; }
}
try { await refreshAutomationRuntimeStatus(); } catch {}
refreshAutomationControlCenter();
// Start/stop remote server based on settings
const remoteSettings = globalSettings.remote;
@@ -8394,6 +8804,7 @@ function prepareForWindowClose(attempt) {
// --- Setup Listeners ---
function setupListeners() {
ensureAutomationPauseResumeButton();
try { initMenuBar(); } catch (err) { console.error('menu bar init failed', err); }
document.querySelectorAll('[data-upload-sidebar-target]').forEach(button => {
button.addEventListener('click', () => setUploadSidebarFilter(button.dataset.uploadSidebarTarget));
@@ -8480,7 +8891,7 @@ function setupListeners() {
});
document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs);
document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs);
document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress);
document.getElementById('automationPauseResumeBtn').addEventListener('click', toggleAutomationPauseResume);
document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads);
document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top'));
document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up'));
+55
View File
@@ -250,6 +250,61 @@
['Wartezeit zwischen Runden', 'Delay between rounds'],
['Minuten · jede weitere Runde wartet entsprechend länger', 'Minutes · each additional round waits proportionally longer'],
['Ordnerüberwachung', 'Folder monitoring'],
['Ordnerüberwachung testen', 'Test folder monitoring'],
['Test der Ordnerüberwachung', 'Folder monitoring test'],
['Ordner wird geprüft…', 'Scanning folder…'],
['Der Test verändert weder Queue noch Einstellungen.', 'The test does not change the queue or settings.'],
['Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.', 'Checks the current folder read-only with the same rules.'],
['Ordnerüberwachung konnte nicht getestet werden.', 'Folder monitoring could not be tested.'],
['Maximale automatische Queue-Größe', 'Maximum automatic queue size'],
['Abgleichintervall', 'Reconciliation interval'],
['Abschließen und pausieren', 'Finish and pause'],
['Fortsetzen', 'Resume'],
['Pausiert', 'Paused'],
['Queue-Limit erreicht', 'Queue limit reached'],
['Ordner getrennt', 'Folder disconnected'],
['Überwachung läuft seit', 'Monitoring since'],
['Ordner erreichbar', 'Folder reachable'],
['Letzte erkannte Datei', 'Last detected file'],
['Heute erkannt', 'Detected today'],
['Heute eingereiht', 'Queued today'],
['Heute übersprungen', 'Skipped today'],
['Wegen Queue-Limit zurückgestellt', 'Deferred by queue limit'],
['Aktuelle Queue-Auslastung', 'Current queue usage'],
['Letzter Abgleich', 'Last reconciliation'],
['Nächster Abgleich', 'Next reconciliation'],
['Letzter Fehler', 'Last error'],
['Nie', 'Never'],
['Ja', 'Yes'],
['Nein', 'No'],
['Keine Datei erkannt', 'No file detected'],
['Gefundene Dateien', 'Files found'],
['Passend zum Dateifilter', 'Matching file filter'],
['Bereits verarbeitet', 'Already processed'],
['Fehlend, leer oder nicht lesbar', 'Missing, empty, or unreadable'],
['Durch Größenlimits ausgeschlossen', 'Excluded by size limits'],
['Entstehende Upload-Jobs', 'Resulting upload jobs'],
['Verfügbare Jobs bis zum Queue-Limit', 'Available jobs before queue limit'],
['Aktuell zurückzustellende Dateien', 'Files currently deferred'],
['0 = unbegrenzt', '0 = unlimited'],
['Unbegrenzt', 'Unlimited'],
['1 Minute', '1 minute'],
['5 Minuten', '5 minutes'],
['15 Minuten', '15 minutes'],
['30 Minuten', '30 minutes'],
['60 Minuten', '60 minutes'],
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
['Ordner nicht erreichbar', 'Folder unavailable'],
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
['Keine Ordnerkonfiguration vorhanden', 'No folder configuration is available'],
['Jobs konnten nicht eindeutig bestätigt werden.', 'Jobs could not be confirmed unambiguously.'],
['Jobs konnten nicht hinzugefügt werden.', 'Jobs could not be added.'],
['Automatische Aufnahme konnte nicht abgeschlossen werden.', 'Automatic admission could not be completed.'],
['Upload läuft bereits.', 'An upload is already running.'],
['Keine startbaren Jobs vorhanden.', 'No startable jobs are available.'],
['Inaktiv', 'Inactive'],
['Ordnerpfad', 'Folder path'],
['Wählen', 'Choose'],
+125 -1
View File
@@ -1258,6 +1258,114 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
border-bottom: 1px solid var(--border);
}
.settings-page-header + .settings-section-label { margin-top: 0; }
.automation-status-card {
min-width: 0;
margin: 0 0 22px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: linear-gradient(145deg, color-mix(in srgb, var(--bg-raised) 94%, var(--accent) 6%), var(--bg-secondary));
box-shadow: 0 14px 32px rgba(0, 0, 0, .14);
}
.automation-status-card * { min-width: 0; }
.automation-status-header {
display: grid;
grid-template-columns: auto minmax(240px, 1fr);
align-items: center;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.automation-state-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
line-height: 1.2;
white-space: nowrap;
}
.automation-state-badge.state-inactive { border-color: var(--border-hover); background: rgba(255, 255, 255, .05); color: var(--text-muted); }
.automation-state-badge.state-active { border-color: rgba(16, 185, 129, .42); background: rgba(16, 185, 129, .14); color: #50e3a4; }
.automation-state-badge.state-paused { border-color: rgba(59, 130, 246, .46); background: rgba(59, 130, 246, .16); color: #7db5ff; }
.automation-state-badge.state-queue-limited { border-color: rgba(245, 158, 11, .5); background: rgba(245, 158, 11, .15); color: #fbbf24; }
.automation-state-badge.state-disconnected { border-color: rgba(249, 115, 22, .5); background: rgba(249, 115, 22, .15); color: #fb923c; }
.automation-state-badge.state-error { border-color: rgba(239, 68, 68, .5); background: rgba(239, 68, 68, .15); color: #f87171; }
.automation-queue-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px 12px; }
.automation-queue-label { color: var(--text-muted); font-size: 11px; font-weight: 600; }
.automation-queue-value { text-align: right; white-space: nowrap; }
.automation-queue-track {
grid-column: 1 / -1;
height: 7px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: 999px;
background: rgba(0, 0, 0, .28);
}
.automation-queue-bar { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--success), var(--accent)); transition: width .2s ease; }
.automation-status-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--border);
}
.automation-status-metric {
display: grid;
grid-template-rows: minmax(30px, auto) minmax(22px, auto);
align-content: start;
gap: 4px;
padding: 9px 10px;
background: var(--bg-secondary);
}
.automation-status-metric > span { color: var(--text-dim); font-size: 10px; line-height: 1.35; overflow-wrap: anywhere; }
.automation-status-value { color: var(--text); font-size: 12px; font-variant-numeric: tabular-nums; line-height: 1.35; overflow-wrap: anywhere; }
.automation-status-error {
display: grid;
grid-template-columns: minmax(120px, auto) minmax(0, 1fr);
gap: 12px;
margin-top: 10px;
padding: 9px 10px;
border: 1px solid rgba(239, 68, 68, .38);
border-radius: 8px;
background: rgba(239, 68, 68, .1);
color: #fca5a5;
font-size: 11px;
}
.automation-status-error[hidden] { display: none; }
.automation-test-action-row { display: grid !important; grid-template-columns: minmax(0, 1fr) auto; }
.automation-test-action-copy { display: grid; gap: 4px; }
.automation-test-action-copy > strong { color: var(--text); font-size: 12px; }
.automation-capacity-row > .hs-input { width: 120px; }
.automation-interval-row > .hs-input { min-width: 150px; }
.toolbar-btn.automation-pause-resume-btn { width: auto; min-width: 150px; gap: 7px; padding: 3px 9px; color: var(--text); }
.automation-pause-resume-label { font-size: 11px; font-weight: 700; white-space: nowrap; }
.toolbar-btn.automation-pause-resume-btn.automation-resume { border-color: rgba(16, 185, 129, .48); background: rgba(16, 185, 129, .17); color: #5ee6ac; }
.toolbar-btn.automation-pause-resume-btn.automation-resume:hover { border-color: rgba(16, 185, 129, .7); background: rgba(16, 185, 129, .26); }
.ctx-item-disabled { opacity: .42; pointer-events: none; }
.automation-test-overlay[aria-hidden="true"] { display: none; }
.automation-test-modal { width: min(720px, 100%); }
.automation-test-header p { margin: 4px 0 0; color: var(--text-muted); font-size: 11px; line-height: 1.45; }
.automation-test-body { overflow-y: auto; }
.automation-test-loading { min-height: 160px; display: grid; place-items: center; align-content: center; gap: 12px; color: var(--text-muted); font-size: 12px; }
.automation-test-loading[hidden] { display: none; }
.automation-test-spinner { width: 28px; height: 28px; border: 3px solid rgba(255, 255, 255, .14); border-top-color: var(--accent); border-radius: 50%; animation: automation-test-spin .8s linear infinite; }
.automation-test-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.automation-test-metrics[hidden] { display: none; }
.automation-test-metric { display: grid; grid-template-columns: minmax(0, 1fr) minmax(64px, auto); align-items: center; gap: 12px; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-secondary); }
.automation-test-label { color: var(--text-muted); font-size: 11px; line-height: 1.35; overflow-wrap: anywhere; }
.automation-test-value { color: var(--text); font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; }
.automation-test-error { margin: 12px 0 0; padding: 10px; border: 1px solid rgba(239, 68, 68, .4); border-radius: 7px; background: rgba(239, 68, 68, .1); color: #fca5a5; font-size: 11px; }
.automation-test-error[hidden] { display: none; }
.automation-test-footer { justify-content: flex-end; }
@keyframes automation-test-spin { to { transform: rotate(360deg); } }
.settings-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -4649,9 +4757,25 @@ input[type="checkbox"] {
}
@media (max-width: 900px) {
.automation-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 820px) {
.automation-status-header,
.automation-test-action-row { grid-template-columns: minmax(0, 1fr); }
.automation-state-badge { justify-self: start; white-space: normal; }
.automation-status-metrics,
.automation-test-metrics { grid-template-columns: minmax(0, 1fr); }
.automation-test-action-row .btn { width: 100%; }
.toolbar-btn.automation-pause-resume-btn { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.header-update-button.is-checking .header-action-icon,
.header-update-button.is-downloading .header-action-icon {
.header-update-button.is-downloading .header-action-icon,
.automation-test-spinner {
animation: none;
}
.automation-queue-bar { transition: none; }
}
+60
View File
@@ -59,6 +59,66 @@ test('translates settings search result labels in both directions', () => {
assert.equal(translateText('Suchergebnisse', 'en'), 'Search results');
});
test('translates every automation control center label in both directions', () => {
const pairs = [
['Ordnerüberwachung testen', 'Test folder monitoring'],
['Maximale automatische Queue-Größe', 'Maximum automatic queue size'],
['Abgleichintervall', 'Reconciliation interval'],
['Abschließen und pausieren', 'Finish and pause'],
['Fortsetzen', 'Resume'],
['Queue-Limit erreicht', 'Queue limit reached'],
['Ordner getrennt', 'Folder disconnected'],
['Pausiert', 'Paused'],
['Wegen Queue-Limit zurückgestellt', 'Deferred by queue limit'],
['Überwachung läuft seit', 'Monitoring since'],
['Ordner erreichbar', 'Folder reachable'],
['Letzte erkannte Datei', 'Last detected file'],
['Heute erkannt', 'Detected today'],
['Heute eingereiht', 'Queued today'],
['Heute übersprungen', 'Skipped today'],
['Aktuelle Queue-Auslastung', 'Current queue usage'],
['Letzter Abgleich', 'Last reconciliation'],
['Nächster Abgleich', 'Next reconciliation'],
['Letzter Fehler', 'Last error'],
['Nie', 'Never'],
['Ja', 'Yes'],
['Nein', 'No'],
['Keine Datei erkannt', 'No file detected'],
['Test der Ordnerüberwachung', 'Folder monitoring test'],
['Ordner wird geprüft…', 'Scanning folder…'],
['Der Test verändert weder Queue noch Einstellungen.', 'The test does not change the queue or settings.'],
['Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.', 'Checks the current folder read-only with the same rules.'],
['Ordnerüberwachung konnte nicht getestet werden.', 'Folder monitoring could not be tested.'],
['Gefundene Dateien', 'Files found'],
['Passend zum Dateifilter', 'Matching file filter'],
['Bereits verarbeitet', 'Already processed'],
['Fehlend, leer oder nicht lesbar', 'Missing, empty, or unreadable'],
['Durch Größenlimits ausgeschlossen', 'Excluded by size limits'],
['Entstehende Upload-Jobs', 'Resulting upload jobs'],
['Verfügbare Jobs bis zum Queue-Limit', 'Available jobs before queue limit'],
['Aktuell zurückzustellende Dateien', 'Files currently deferred'],
['0 = unbegrenzt', '0 = unlimited'],
['Unbegrenzt', 'Unlimited'],
['1 Minute', '1 minute'],
['5 Minuten', '5 minutes'],
['15 Minuten', '15 minutes'],
['30 Minuten', '30 minutes'],
['60 Minuten', '60 minutes'],
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
['Ordner nicht erreichbar', 'Folder unavailable'],
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
['Keine Ordnerkonfiguration vorhanden', 'No folder configuration is available']
];
for (const [german, english] of pairs) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('translates account cooldown and manual pause labels', () => {
const pairs = [
['Pausiert noch', 'Paused '],
+664 -53
View File
@@ -64,10 +64,13 @@ test('Windows compositor paints the full hidden surface with an RDP session envi
const { contextBridge } = require('electron');
const managedOnlineBackupProbeCalls = [];
const folderMonitorProbeCalls = [];
const automationStatusListeners = [];
let pendingAutomationTestScan = null;
let automationProbe = {
history: [],
uploadLog: [],
paused: false,
runtimeStatus: {},
automationStatusSequence: [],
historyError: '',
addResult: null,
@@ -76,6 +79,8 @@ let automationProbe = {
startResult: null,
startError: '',
saveSettingsError: '',
testScanError: '',
deferTestScan: false,
dryScan: { files: [], reachable: true, trigger: 'test' },
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
mutationCalls: [],
@@ -163,10 +168,12 @@ contextBridge.exposeInMainWorld('api', {
},
getManagedOnlineBackupProbeCalls() { return managedOnlineBackupProbeCalls; },
configureAutomationProbe(value = {}) {
pendingAutomationTestScan = null;
automationProbe = {
history: Array.isArray(value.history) ? value.history : [],
uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [],
paused: value.paused === true,
runtimeStatus: value.runtimeStatus && typeof value.runtimeStatus === 'object' ? { ...value.runtimeStatus } : {},
automationStatusSequence: Array.isArray(value.automationStatusSequence) ? value.automationStatusSequence.map(entry => ({ ...entry })) : [],
historyError: String(value.historyError || ''),
addResult: value.addResult || null,
@@ -175,6 +182,8 @@ contextBridge.exposeInMainWorld('api', {
startResult: value.startResult || null,
startError: String(value.startError || ''),
saveSettingsError: String(value.saveSettingsError || ''),
testScanError: String(value.testScanError || ''),
deferTestScan: value.deferTestScan === true,
dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' },
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
mutationCalls: [],
@@ -229,12 +238,41 @@ contextBridge.exposeInMainWorld('api', {
automationGetStatus() {
automationProbe.readCalls.status++;
if (automationProbe.automationStatusSequence.length > 0) return Promise.resolve(automationProbe.automationStatusSequence.shift());
return Promise.resolve({ paused: automationProbe.paused });
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: automationProbe.paused });
},
automationPauseAfterActive() {
automationProbe.mutationCalls.push(['pause']);
automationProbe.paused = true;
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: true, pausedAt: 1787712000000 });
},
automationResume() {
automationProbe.mutationCalls.push(['resume']);
automationProbe.paused = false;
return Promise.resolve({ ...automationProbe.runtimeStatus, paused: false, pausedAt: null });
},
onAutomationStatus(listener) {
automationStatusListeners.push(listener);
return () => {
const index = automationStatusListeners.indexOf(listener);
if (index >= 0) automationStatusListeners.splice(index, 1);
};
},
emitAutomationStatus(status) {
automationStatusListeners.forEach(listener => listener({ ...status }));
},
folderMonitorTestScan() {
automationProbe.readCalls.testScan++;
if (automationProbe.testScanError) return Promise.reject(new Error(automationProbe.testScanError));
if (automationProbe.deferTestScan) {
return new Promise(resolve => { pendingAutomationTestScan = resolve; });
}
return Promise.resolve(automationProbe.dryScan);
},
releaseAutomationTestScan() {
const resolve = pendingAutomationTestScan;
pendingAutomationTestScan = null;
if (resolve) resolve(automationProbe.dryScan);
},
folderMonitorReconcile() {
automationProbe.readCalls.reconcile++;
return Promise.resolve(automationProbe.dryScan);
@@ -1224,7 +1262,7 @@ contextBridge.exposeInMainWorld('api', {
job.sourceCleanupFingerprint = { index, nested: ['before-' + index] };
return job;
};
const summarizeCollisionPath = async (jobs, invalidJobs, result, before) => {
const summarizeCollisionPath = async (jobs, invalidJobs, validJobs, result, before) => {
const probe = await window.api.getAutomationProbeState();
const inject = probe.mutationCalls.find(call => call[0] === 'inject');
return {
@@ -1233,7 +1271,13 @@ contextBridge.exposeInMainWorld('api', {
sentJobs: inject?.[3] || [],
sourceCleanupGroups: inject?.[4] || [],
statuses: jobs.map(job => job.status),
invalidRestored: invalidJobs.map((job, index) => JSON.stringify(job) === before[index])
invalidRestored: invalidJobs.map((job, index) => JSON.stringify(job) === before[index]),
validJobs: validJobs.map(job => ({
id: job.id,
status: job.status,
requiredHosters: [...(job.sourceCleanupRequiredHosters || [])].sort(),
fingerprint: clone(job.sourceCleanupFingerprint)
}))
};
};
@@ -1248,10 +1292,11 @@ contextBridge.exposeInMainWorld('api', {
{ ...makePauseRaceJob('active-duplicate-b.mkv'), id: 'active-duplicate' },
activeMissingJob,
{ ...makePauseRaceJob('active-shadowed.mkv'), id: 'active-shadowed' },
{ ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' }
{ ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' },
{ ...makePauseRaceJob('active-already.mkv'), id: 'active-already' }
].map((job, index) => {
job.file = activeCollisionFile;
job.hoster = [hosters[0], hosters[1], hosters[2], hosters[0], hosters[3]][index];
job.hoster = [hosters[0], hosters[1], hosters[2], hosters[0], hosters[3], hosters[1]][index];
return applyCollisionCleanupFixture(job, index);
});
const activeShadowSibling = applyCollisionCleanupFixture({
@@ -1261,13 +1306,42 @@ contextBridge.exposeInMainWorld('api', {
hoster: hosters[1],
status: 'done'
}, 5);
queueJobs = [...activeCollisionJobs, activeShadowSibling];
const activeMainFingerprint = { size: 11, mtimeMs: 22, headHash: 'active-main' };
const activeValidToken = 'active-valid-token';
activeCollisionJobs[0].sourceCleanupToken = activeValidToken;
activeCollisionJobs[4].sourceCleanupToken = activeValidToken;
activeCollisionJobs[4].sourceCleanupRequiredHosters = [];
activeCollisionJobs[5].sourceCleanupToken = activeValidToken;
activeCollisionJobs[5].sourceCleanupRequiredHosters = [];
const activeValidSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('active-running-sibling.mkv'),
id: 'active-running-sibling',
file: activeCollisionFile,
hoster: 'clouddrop.cc',
status: 'uploading'
}, 6);
activeValidSibling.sourceCleanupToken = activeValidToken;
activeValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
queueJobs = [...activeCollisionJobs, activeShadowSibling, activeValidSibling];
rebuildJobIndex();
const activeInvalidJobs = [...activeCollisionJobs.slice(0, 4), activeShadowSibling];
const activeCollisionBefore = activeInvalidJobs.map(job => JSON.stringify(job));
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
window.api.configureAutomationProbe({
paused: false,
addResult: {
added: 1,
alreadyInBatchJobIds: ['active-already'],
sourceCleanupFingerprints: { [activeValidToken]: activeMainFingerprint }
}
});
const activeCollisionResult = await startSelectedUpload(activeCollisionJobs);
const activeCollision = await summarizeCollisionPath(activeCollisionJobs, activeInvalidJobs, activeCollisionResult, activeCollisionBefore);
const activeCollision = await summarizeCollisionPath(
activeCollisionJobs,
activeInvalidJobs,
[activeCollisionJobs[4], activeCollisionJobs[5], activeValidSibling],
activeCollisionResult,
activeCollisionBefore
);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
@@ -1298,6 +1372,20 @@ contextBridge.exposeInMainWorld('api', {
manualCollisionJobs[2].id = 'manual-shadowed';
manualCollisionJobs[3].id = 'manual-unique';
manualCollisionJobs.forEach(applyCollisionCleanupFixture);
const manualValidToken = 'manual-valid-token';
manualCollisionJobs[0].sourceCleanupToken = manualValidToken;
manualCollisionJobs[3].sourceCleanupToken = manualValidToken;
manualCollisionJobs[3].sourceCleanupRequiredHosters = [];
const manualAlready = applyCollisionCleanupFixture({
...makePauseRaceJob('manual-already.mkv'),
id: 'manual-already',
file: manualCollisionFile.path,
hoster: 'clouddrop.cc',
status: 'preview'
}, 6);
manualAlready.sourceCleanupToken = manualValidToken;
manualAlready.sourceCleanupRequiredHosters = [];
manualCollisionJobs.push(manualAlready);
const manualShadowSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('manual-shadow-sibling.mkv'),
id: 'manual-shadowed',
@@ -1312,19 +1400,43 @@ contextBridge.exposeInMainWorld('api', {
status: 'done'
}, 5);
delete manualMissingSibling.id;
queueJobs.push(manualShadowSibling, manualMissingSibling);
const manualValidSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('manual-running-sibling.mkv'),
id: 'manual-running-sibling',
file: manualCollisionFile.path,
hoster: 'voe.sx',
status: 'uploading'
}, 7);
manualValidSibling.sourceCleanupToken = manualValidToken;
manualValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
queueJobs.push(manualAlready, manualShadowSibling, manualMissingSibling, manualValidSibling);
manualInvalidJobs = [...manualCollisionJobs.slice(0, 3), manualShadowSibling, manualMissingSibling];
manualCollisionBefore = manualInvalidJobs.map(job => JSON.stringify(job));
manualCollisionJobs.validJobs = [manualCollisionJobs[3], manualAlready, manualValidSibling];
rebuildJobIndex();
};
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
const manualMainFingerprint = { size: 33, mtimeMs: 44, headHash: 'manual-main' };
window.api.configureAutomationProbe({
paused: false,
addResult: {
added: 1,
alreadyInBatchJobIds: ['manual-already'],
sourceCleanupFingerprints: { 'manual-valid-token': manualMainFingerprint }
}
});
const manualCollisionResult = await applyHosterSelection();
buildQueuePreview = originalBuildQueuePreviewForCollision;
const manualCollision = await summarizeCollisionPath(manualCollisionJobs, manualInvalidJobs, manualCollisionResult, manualCollisionBefore);
const manualCollision = await summarizeCollisionPath(
manualCollisionJobs,
manualInvalidJobs,
manualCollisionJobs.validJobs || [],
manualCollisionResult,
manualCollisionBefore
);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
config.globalSettings.folderMonitor.hosters = hosters.slice();
config.globalSettings.folderMonitor.hosters = HOSTERS.slice();
config.globalSettings.folderMonitor.autoStart = true;
selectedFiles = [];
uploading = true;
@@ -1345,7 +1457,16 @@ contextBridge.exposeInMainWorld('api', {
delete automationMissingSibling.id;
automationShadowSibling.sourceCleanupToken = 'automation-collision-token';
automationMissingSibling.sourceCleanupToken = 'automation-collision-token';
queueJobs.push(automationShadowSibling, automationMissingSibling);
const automationValidSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('automation-running-sibling.mkv'),
id: 'automation-running-sibling',
file: 'C:\\collision\\automation-running-existing.mkv',
hoster: 'voe.sx',
status: 'uploading'
}, 6);
automationValidSibling.sourceCleanupToken = 'automation-collision-token';
automationValidSibling.sourceCleanupRequiredHosters = ['removed.example'];
queueJobs.push(automationShadowSibling, automationMissingSibling, automationValidSibling);
rebuildJobIndex();
const automationExternalBefore = [automationShadowSibling, automationMissingSibling].map(job => JSON.stringify(job));
const originalCreateAutomationPreviewJobForCollision = createAutomationPreviewJob;
@@ -1356,23 +1477,39 @@ contextBridge.exposeInMainWorld('api', {
const index = automationCollisionJobs.length;
if (index < 2) job.id = 'automation-duplicate';
else if (index === 2) job.id = 'automation-shadowed';
else job.id = 'automation-unique';
else if (index === 3) job.id = 'automation-unique';
else job.id = 'automation-already';
applyCollisionCleanupFixture(job, index);
job.sourceCleanupToken = 'automation-collision-token';
if (index >= 3) job.sourceCleanupRequiredHosters = [];
automationCollisionJobs.push(job);
if (index < 3) automationCollisionBefore.push(JSON.stringify(job));
return job;
};
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
const automationMainFingerprint = { size: 55, mtimeMs: 66, headHash: 'automation-main' };
window.api.configureAutomationProbe({
paused: false,
addResult: {
added: 1,
alreadyInBatchJobIds: ['automation-already'],
sourceCleanupFingerprints: { 'automation-collision-token': automationMainFingerprint }
}
});
const automationCollisionEvaluation = await evaluateAutomationCandidates([automationCollisionFile], { dryRun: false, trigger: 'watcher' });
const automationCollisionResult = await applyAutomationEvaluation(automationCollisionEvaluation);
createAutomationPreviewJob = originalCreateAutomationPreviewJobForCollision;
const automationInvalidJobs = [...automationCollisionJobs.slice(0, 3), automationShadowSibling, automationMissingSibling];
const automationCollision = await summarizeCollisionPath(automationCollisionJobs, automationInvalidJobs, {
const automationCollision = await summarizeCollisionPath(
automationCollisionJobs,
automationInvalidJobs,
[automationCollisionJobs[3], automationCollisionJobs[4], automationValidSibling],
{
ok: automationCollisionResult.ok,
error: automationCollisionResult.error,
error: automationCollisionResult.error || null,
admitted: automationCollisionResult.admittedFiles.map(file => file.name)
}, [...automationCollisionBefore, ...automationExternalBefore]);
},
[...automationCollisionBefore, ...automationExternalBefore]
);
uploading = false;
const collisionAdmission = { active: activeCollision, manual: manualCollision, automation: automationCollision };
@@ -1570,6 +1707,293 @@ contextBridge.exposeInMainWorld('api', {
};
return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
})()`;
const automationControlCenterScript = `(async () => {
const waitFor = async predicate => {
for (let attempt = 0; attempt < 80; attempt++) {
if (await predicate()) return true;
await new Promise(resolve => setTimeout(resolve, 0));
}
return false;
};
const fixedNow = 1787712600000;
const runtimeStatus = {
running: true,
reachable: true,
scanning: false,
folderPath: 'C:\\\\watch',
lastScanAt: fixedNow - 60000,
startedAt: fixedNow - 3600000,
error: ''
};
setUiLanguage('de');
config = {
hosters: Object.fromEntries(HOSTERS.map(hoster => [hoster, []])),
hosterSettings: {},
globalSettings: {
language: 'de',
folderMonitor: {
enabled: true,
folderPath: 'C:\\\\watch',
hosters: ['doodstream.com'],
autoStart: false,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null,
telemetry: {
dateKey: new Date().toLocaleDateString('en-CA'),
detected: 23,
queued: 17,
skipped: 5,
deferred: 2,
lastDetectedName: 'episode-08.mkv',
lastDetectedAt: fixedNow - 120000,
lastError: '',
lastErrorAt: null
}
}
}
};
hosterSettings = {};
selectedFiles = [];
selectedUploadHosters = ['doodstream.com'];
queueJobs = Array.from({ length: 8420 }, (_, index) => ({
id: 'ui-capacity-' + index,
file: 'C:\\\\queue\\\\' + index + '.mkv',
fileName: index + '.mkv',
hoster: 'doodstream.com',
status: 'queued',
bytesTotal: 1
}));
rebuildJobIndex();
applyAutomationRuntimeStatus(runtimeStatus);
renderSettings();
document.querySelector('[data-settings-page="automatik"]')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
const automationPage = document.querySelector('[data-subpage="automatik"]');
const pageHeader = automationPage?.querySelector('.settings-page-header');
const queueLimitInput = document.getElementById('fmQueueLimitInput');
const intervalInput = document.getElementById('fmReconcileIntervalInput');
const initial = {
cardImmediatelyAfterHeader: pageHeader?.nextElementSibling?.id === 'automationStatusCard',
stateBadge: {
text: document.getElementById('automationStateBadge')?.textContent.trim() || null,
classes: [...(document.getElementById('automationStateBadge')?.classList || [])]
},
queueMeter: document.getElementById('automationQueueMeter')?.textContent.trim() || null,
lastErrorHidden: document.getElementById('automationLastErrorRow')?.hidden ?? null,
queueLimitDefault: queueLimitInput?.value || null,
queueLimitMin: queueLimitInput?.min || null,
intervalDefault: intervalInput?.value || null,
intervalOptions: [...(intervalInput?.options || [])].map(option => option.value),
snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry)
};
if (queueLimitInput) {
queueLimitInput.value = '0';
queueLimitInput.dispatchEvent(new Event('input', { bubbles: true }));
}
initial.queueLimitAcceptsZero = queueLimitInput?.value === '0' && queueLimitInput.checkValidity();
const stateDefinitions = [
['inactive', 'Inaktiv', 'state-inactive'],
['active', 'Aktiv', 'state-active'],
['paused', 'Pausiert', 'state-paused'],
['queue-limited', 'Queue-Limit erreicht', 'state-queue-limited'],
['disconnected', 'Ordner getrennt', 'state-disconnected'],
['error', 'Fehler', 'state-error']
];
const states = [];
if (typeof renderAutomationStatusSnapshot === 'function') {
const baseSnapshot = createAutomationStatusSnapshot();
for (const [state, label, className] of stateDefinitions) {
renderAutomationStatusSnapshot(Object.freeze({ ...baseSnapshot, state, error: state === 'error' ? 'Fehler beim Scan' : '' }));
const badge = document.getElementById('automationStateBadge');
states.push({ state, expectedLabel: label, text: badge?.textContent.trim(), classApplied: badge?.classList.contains(className) });
}
renderAutomationStatusSnapshot(baseSnapshot);
}
const originalSnapshotFactory = createAutomationStatusSnapshot;
let snapshotCalls = 0;
const pausedSnapshot = Object.freeze({
...originalSnapshotFactory(),
paused: true,
state: 'paused',
telemetry: Object.freeze({ ...originalSnapshotFactory().telemetry })
});
if (typeof refreshAutomationControlCenter === 'function') {
createAutomationStatusSnapshot = () => {
snapshotCalls++;
return pausedSnapshot;
};
refreshAutomationControlCenter();
createAutomationStatusSnapshot = originalSnapshotFactory;
}
queueJobs = [
{ id: 'ui-preview', file: 'C:\\\\ui-preview.mkv', fileName: 'ui-preview.mkv', hoster: 'doodstream.com', status: 'preview', bytesTotal: 1 },
{ id: 'ui-error', file: 'C:\\\\ui-error.mkv', fileName: 'ui-error.mkv', hoster: 'doodstream.com', status: 'error', bytesTotal: 1 }
];
uploadSidebarFilter = 'all';
queueSearchQuery = '';
queueHosterFilter = '';
queueStatusFilter = '';
_queueFilterCache = { filter: '', source: null, result: [] };
selectedJobIds.clear();
selectedJobIds.add('ui-preview');
selectedJobIds.add('ui-error');
rebuildJobIndex();
updateUploadView({ rebuildPreview: false });
config.globalSettings.folderMonitor.paused = true;
applyAutomationRuntimeStatus({ ...runtimeStatus, paused: true, pausedAt: fixedNow });
updateQueueActionButtons();
document.querySelector('[data-view="upload"]')?.click();
const pauseButton = document.getElementById('automationPauseResumeBtn');
const pausedControls = {
snapshotCalls,
pauseButtonDisabled: pauseButton?.disabled ?? null,
pauseButtonText: pauseButton?.textContent.trim() || null,
pauseButtonLabel: pauseButton?.getAttribute('aria-label') || null,
pauseButtonGreen: pauseButton?.classList.contains('automation-resume') ?? null,
pauseButtonFits: pauseButton ? pauseButton.scrollWidth <= pauseButton.clientWidth + 1 && pauseButton.getBoundingClientRect().width > 34 : null,
startDisabled: Object.fromEntries(['startUploadBtn', 'startSelectedBtn', 'reuploadSelectedBtn', 'retryFailedBtn'].map(id => [id, document.getElementById(id)?.disabled ?? null])),
contextStartDisabled: document.querySelector('[data-action="start-selected"]')?.getAttribute('aria-disabled') || null,
contextRetryDisabled: document.querySelector('[data-action="retry-selected"]')?.getAttribute('aria-disabled') || null
};
window.api.configureAutomationProbe({ paused: true, runtimeStatus });
pauseButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
const afterResumeProbe = await window.api.getAutomationProbeState();
const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
selectedJobIds.clear();
selectedJobIds.add('ui-preview');
selectedJobIds.add('ui-error');
updateQueueActionButtons();
const startDisabledAfterResume = Object.fromEntries(['startUploadBtn', 'startSelectedBtn', 'reuploadSelectedBtn', 'retryFailedBtn'].map(id => [id, document.getElementById(id)?.disabled ?? null]));
document.getElementById('automationPauseResumeBtn')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
const afterPauseProbe = await window.api.getAutomationProbeState();
const pausedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
setUiLanguage('en');
await new Promise(resolve => setTimeout(resolve, 0));
const pausedLabelEnglish = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
setUiLanguage('de');
const pauseResumeActions = {
calls: afterPauseProbe.mutationCalls.filter(call => call[0] === 'resume' || call[0] === 'pause').map(call => call[0]),
resumedLabel,
startDisabledAfterResume,
pausedLabel,
pausedLabelEnglish,
configPaused: config.globalSettings.folderMonitor.paused
};
document.querySelector('[data-view="settings"]')?.click();
document.querySelector('[data-settings-page="automatik"]')?.click();
config.globalSettings.folderMonitor.paused = false;
applyAutomationRuntimeStatus(runtimeStatus);
queueJobs = Array.from({ length: 8420 }, (_, index) => ({
id: 'ui-test-' + index,
file: 'C:\\\\queue-test\\\\' + index + '.mkv',
fileName: index + '.mkv',
hoster: 'doodstream.com',
status: 'queued',
bytesTotal: 1
}));
rebuildJobIndex();
if (typeof refreshAutomationControlCenter === 'function') refreshAutomationControlCenter();
const dryFiles = [
{ path: 'C:\\\\watch\\\\accepted.mkv', name: 'accepted.mkv', size: 1, mtimeMs: 1, filterMatched: true },
{ path: 'C:\\\\watch\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 2, filterMatched: false },
{ path: 'C:\\\\watch\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 3, filterMatched: true, unavailable: true },
{ path: 'C:\\\\watch\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true }
];
window.api.configureAutomationProbe({
paused: false,
runtimeStatus,
deferTestScan: true,
dryScan: { files: dryFiles, reachable: true, trigger: 'test' },
history: [{ id: 'ui-history', files: [{ path: dryFiles[3].path, name: dryFiles[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
});
const beforeTestProbe = await window.api.getAutomationProbeState();
const testButton = document.getElementById('automationTestBtn');
testButton?.focus();
testButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
const overlay = document.getElementById('automationTestOverlay');
const loading = {
visible: overlay?.style.display === 'flex' && overlay?.getAttribute('aria-hidden') === 'false',
busy: overlay?.getAttribute('aria-busy') || null,
spinnerVisible: document.getElementById('automationTestSpinner')?.hidden === false,
focusInside: Boolean(overlay?.contains(document.activeElement)),
backgroundInert: Array.from(document.body.children).filter(element => element !== overlay && 'inert' in element).every(element => element.inert)
};
window.api.releaseAutomationTestScan();
await waitFor(() => overlay?.getAttribute('aria-busy') === 'false');
const metricValues = Object.fromEntries([...document.querySelectorAll('[data-automation-test-metric]')].map(row => [
row.dataset.automationTestMetric,
row.querySelector('.automation-test-value')?.textContent.trim() || ''
]));
const germanMetricLabels = [...document.querySelectorAll('[data-automation-test-metric] .automation-test-label')].map(element => element.textContent.trim());
const afterTestProbe = await window.api.getAutomationProbeState();
const completed = {
metricValues,
metricCount: Object.keys(metricValues).length,
germanMetricLabels,
errorHidden: document.getElementById('automationTestError')?.hidden ?? null,
actionIds: [...(overlay?.querySelectorAll('button') || [])].map(button => button.id),
mutationFree: JSON.stringify(afterTestProbe.mutationCalls) === JSON.stringify(beforeTestProbe.mutationCalls)
};
setUiLanguage('en');
await new Promise(resolve => setTimeout(resolve, 0));
const english = {
queueMeter: document.getElementById('automationQueueMeter')?.textContent.trim() || null,
availableSlots: document.querySelector('[data-automation-test-metric="availableSlots"] .automation-test-value')?.textContent.trim() || null,
metricLabels: [...document.querySelectorAll('[data-automation-test-metric] .automation-test-label')].map(element => element.textContent.trim())
};
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
const closed = {
hidden: overlay?.style.display === 'none' && overlay?.getAttribute('aria-hidden') === 'true',
focusReturned: document.activeElement?.id === 'automationTestBtn',
backgroundRestored: Array.from(document.body.children).filter(element => element !== overlay && 'inert' in element).every(element => !element.inert)
};
setUiLanguage('de');
window.api.configureAutomationProbe({ paused: false, runtimeStatus, testScanError: 'token=secret-value' });
document.getElementById('automationTestBtn')?.click();
await waitFor(() => document.getElementById('automationTestOverlay')?.getAttribute('aria-busy') === 'false');
const errorText = document.getElementById('automationTestError')?.textContent.trim() || '';
const errorState = {
visible: document.getElementById('automationTestError')?.hidden === false,
text: errorText,
secretExposed: /secret-value|token=/i.test(errorText)
};
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
window.api.configureAutomationProbe({
paused: false,
runtimeStatus,
deferTestScan: true,
dryScan: { files: dryFiles, reachable: true, trigger: 'test' }
});
document.getElementById('automationTestBtn')?.click();
await waitFor(() => document.getElementById('automationTestOverlay')?.getAttribute('aria-busy') === 'true');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
const enabledAfterCancel = document.getElementById('automationTestBtn')?.disabled === false;
window.api.releaseAutomationTestScan();
await new Promise(resolve => setTimeout(resolve, 0));
const cancelLoading = {
hidden: document.getElementById('automationTestOverlay')?.style.display === 'none',
enabledAfterCancel,
lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none'
};
return { initial, states, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading };
})()`;
const automationControlCenterLayoutScript = `(() => {
const card = document.getElementById('automationStatusCard');
const metrics = document.querySelector('.automation-status-metrics');
const metricValue = document.querySelector('.automation-status-value');
return {
viewportWidth: document.documentElement.clientWidth,
documentOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
cardOverflow: card ? card.scrollWidth > card.clientWidth + 1 : null,
metricsOverflow: metrics ? metrics.scrollWidth > metrics.clientWidth + 1 : null,
gridTemplateColumns: metrics ? getComputedStyle(metrics).gridTemplateColumns : null,
tabularNumbers: metricValue ? getComputedStyle(metricValue).fontVariantNumeric.includes('tabular-nums') : null
};
})()`;
const onlineBackupBehaviorScript = `(async () => {
const ids = {
a: 'AAAAAAAAAAAAAAAAAAAAAA',
@@ -1701,41 +2125,52 @@ contextBridge.exposeInMainWorld('api', {
const copy = language === 'de' ? 'Schlüssel kopieren' : 'Copy key';
const remove = language === 'de' ? 'Online-Backup löschen' : 'Delete online backup';
document.documentElement.lang = language;
document.body.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><h4>Managed</h4><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22.08.2026 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article><article class="online-backup-managed-row"><span class="online-backup-managed-key">ZYXWVUTS…9876</span><span class="online-backup-managed-created">21.08.2026 11:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
const panel = document.querySelector('.online-backup-panel');
const fixture = document.createElement('div');
fixture.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><h4>Managed</h4><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22.08.2026 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article><article class="online-backup-managed-row"><span class="online-backup-managed-key">ZYXWVUTS…9876</span><span class="online-backup-managed-created">21.08.2026 11:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">' + copy + '</button><button class="btn btn-danger">' + remove + '</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
document.body.appendChild(fixture);
const panel = fixture.querySelector('.online-backup-panel');
const panelRect = panel.getBoundingClientRect();
const panelStyle = getComputedStyle(panel);
const rows = [...document.querySelectorAll('.online-backup-managed-row')].map(row => {
const rows = [...fixture.querySelectorAll('.online-backup-managed-row')].map(row => {
const key = row.querySelector('.online-backup-managed-key').getBoundingClientRect();
const created = row.querySelector('.online-backup-managed-created').getBoundingClientRect();
const actions = row.querySelector('.online-backup-managed-actions').getBoundingClientRect();
return { keyLeft: key.left, createdLeft: created.left, actionsRight: actions.right };
});
return {
const result = {
rows,
contentRight: panelRect.right - parseFloat(panelStyle.paddingRight),
createRight: document.querySelector('.online-backup-footer button').getBoundingClientRect().right
createRight: fixture.querySelector('.online-backup-footer button').getBoundingClientRect().right
};
fixture.remove();
return result;
};
return { german: measure('de'), english: measure('en') };
})()`;
const onlineBackupNarrowLayoutScript = `(() => {
document.body.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22/08/2026, 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">Copy key</button><button class="btn btn-danger">Delete online backup</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
const row = document.querySelector('.online-backup-managed-row').getBoundingClientRect();
const key = document.querySelector('.online-backup-managed-key').getBoundingClientRect();
const created = document.querySelector('.online-backup-managed-created').getBoundingClientRect();
const actions = document.querySelector('.online-backup-managed-actions').getBoundingClientRect();
const rowStyle = getComputedStyle(document.querySelector('.online-backup-managed-row'));
const fixture = document.createElement('div');
fixture.innerHTML = '<section class="online-backup-panel"><section class="online-backup-managed"><div class="online-backup-managed-list"><article class="online-backup-managed-row"><span class="online-backup-managed-key">ABCDEFGH…1234</span><span class="online-backup-managed-created">22/08/2026, 12:00</span><div class="online-backup-managed-actions"><button class="btn btn-secondary">Copy key</button><button class="btn btn-danger">Delete online backup</button></div></article></div></section><footer class="online-backup-footer"><button class="btn btn-primary">Generate new key</button></footer></section>';
document.body.appendChild(fixture);
const rowElement = fixture.querySelector('.online-backup-managed-row');
const row = rowElement.getBoundingClientRect();
const key = fixture.querySelector('.online-backup-managed-key').getBoundingClientRect();
const created = fixture.querySelector('.online-backup-managed-created').getBoundingClientRect();
const actions = fixture.querySelector('.online-backup-managed-actions').getBoundingClientRect();
const rowStyle = getComputedStyle(rowElement);
const rowContentWidth = row.width - parseFloat(rowStyle.paddingLeft) - parseFloat(rowStyle.paddingRight) - parseFloat(rowStyle.borderLeftWidth) - parseFloat(rowStyle.borderRightWidth);
const footer = document.querySelector('.online-backup-footer').getBoundingClientRect();
const create = document.querySelector('.online-backup-footer button').getBoundingClientRect();
return {
const footer = fixture.querySelector('.online-backup-footer').getBoundingClientRect();
const create = fixture.querySelector('.online-backup-footer button').getBoundingClientRect();
const result = {
innerWidth,
narrowMedia: matchMedia('(max-width: 820px)').matches,
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
rowOverflow: document.querySelector('.online-backup-managed-row').scrollWidth > document.querySelector('.online-backup-managed-row').clientWidth + 1,
rowOverflow: rowElement.scrollWidth > rowElement.clientWidth + 1,
stacked: key.top < created.top && created.top < actions.top,
actionsStretched: Math.abs(actions.width - rowContentWidth) <= 1,
createStretched: Math.abs(create.width - footer.width) <= 1
};
fixture.remove();
return result;
})()`;
const probeSource = `
const { app, BrowserWindow, screen } = require('electron');
@@ -1746,6 +2181,15 @@ function pixelAt(bitmap, width, x, y) {
const offset = (y * width + x) * 4;
return [bitmap[offset + 2], bitmap[offset + 1], bitmap[offset], bitmap[offset + 3]];
}
async function waitForContentWidth(browserWindow, target) {
let width = 0;
for (let attempt = 0; attempt < 80; attempt++) {
width = await browserWindow.webContents.executeJavaScript('innerWidth');
if (Math.abs(width - target) <= 1) return width;
await new Promise(resolve => setTimeout(resolve, 10));
}
return width;
}
app.whenReady().then(async () => {
const display = screen.getPrimaryDisplay();
const requestedContentWidth = Math.min(2544, display.workAreaSize.width);
@@ -1777,9 +2221,12 @@ app.whenReady().then(async () => {
const settingsSearchBehavior = await window.webContents.executeJavaScript(${JSON.stringify(settingsSearchBehaviorScript)});
const folderMonitorBehavior = await window.webContents.executeJavaScript(${JSON.stringify(folderMonitorBehaviorScript)});
const automationPipeline = await window.webContents.executeJavaScript(${JSON.stringify(automationPipelineScript)});
const automationControlCenter = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterScript)});
const automationControlCenterWideLayout = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterLayoutScript)});
const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)});
window.setContentSize(760, Math.min(900, display.workAreaSize.height));
await new Promise(resolve => setTimeout(resolve, 50));
await waitForContentWidth(window, 760);
const automationControlCenterNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(automationControlCenterLayoutScript)});
const onlineBackupNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupNarrowLayoutScript)});
fs.writeFileSync(outputPath, JSON.stringify({
size,
@@ -1796,6 +2243,9 @@ app.whenReady().then(async () => {
settingsSearchBehavior,
folderMonitorBehavior,
automationPipeline,
automationControlCenter,
automationControlCenterWideLayout,
automationControlCenterNarrowLayout,
onlineBackupBehavior,
onlineBackupLayout,
onlineBackupNarrowLayout
@@ -2149,7 +2599,7 @@ app.whenReady().then(async () => {
});
assert.deepEqual(result.automationPipeline.collisionResolver, {
clean: {
consistent: false,
consistent: true,
added: ['resolver-unique.mkv'],
already: [],
skipped: [],
@@ -2165,28 +2615,61 @@ app.whenReady().then(async () => {
});
assert.deepEqual(result.automationPipeline.collisionAdmission, {
active: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['active-unique'],
sentJobs: [{ id: 'active-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['active-unique'] }],
statuses: ['preview', 'preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true, true, true]
result: { ok: true, added: 1 },
sentIds: ['active-unique', 'active-already'],
sentJobs: [
{ id: 'active-unique', requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'] },
{ id: 'active-already', requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'] }
],
sourceCleanupGroups: [{
requiredHosters: ['removed.example', 'byse.sx', 'voe.sx', 'clouddrop.cc'],
jobIds: ['active-unique', 'active-already', 'active-running-sibling']
}],
statuses: ['preview', 'preview', 'preview', 'preview', 'queued', 'queued'],
invalidRestored: [true, true, true, true, true],
validJobs: [
{ id: 'active-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } },
{ id: 'active-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } },
{ id: 'active-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 11, mtimeMs: 22, headHash: 'active-main' } }
]
},
manual: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['manual-unique'],
sentJobs: [{ id: 'manual-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['manual-unique'] }],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true, true, true]
result: true,
sentIds: ['manual-unique', 'manual-already'],
sentJobs: [
{ id: 'manual-unique', requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'] },
{ id: 'manual-already', requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'] }
],
sourceCleanupGroups: [{
requiredHosters: ['removed.example', 'byse.sx', 'clouddrop.cc', 'voe.sx'],
jobIds: ['manual-unique', 'manual-already', 'manual-running-sibling']
}],
statuses: ['preview', 'preview', 'preview', 'queued', 'queued'],
invalidRestored: [true, true, true, true, true],
validJobs: [
{ id: 'manual-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } },
{ id: 'manual-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } },
{ id: 'manual-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 33, mtimeMs: 44, headHash: 'manual-main' } }
]
},
automation: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', admitted: [] },
sentIds: ['automation-unique'],
sentJobs: [{ id: 'automation-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['automation-unique'] }],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true, true, true]
result: { ok: true, error: null, admitted: ['automation.mkv'] },
sentIds: ['automation-unique', 'automation-already'],
sentJobs: [
{ id: 'automation-unique', requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'] },
{ id: 'automation-already', requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'] }
],
sourceCleanupGroups: [{
requiredHosters: ['removed.example', 'voe.sx', 'byse.sx', 'clouddrop.cc'],
jobIds: ['automation-running-sibling', 'automation-unique', 'automation-already']
}],
statuses: ['preview', 'preview', 'preview', 'queued', 'queued'],
invalidRestored: [true, true, true, true, true],
validJobs: [
{ id: 'automation-unique', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } },
{ id: 'automation-already', status: 'queued', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } },
{ id: 'automation-running-sibling', status: 'uploading', requiredHosters: ['byse.sx', 'clouddrop.cc', 'removed.example', 'voe.sx'], fingerprint: { size: 55, mtimeMs: 66, headHash: 'automation-main' } }
]
}
});
assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, {
@@ -2285,6 +2768,132 @@ app.whenReady().then(async () => {
startCalls: 0,
injectCalls: 0
});
assert.equal(result.automationControlCenter.initial.cardImmediatelyAfterHeader, true);
assert.equal(result.automationControlCenter.initial.stateBadge.text, 'Aktiv');
assert.equal(result.automationControlCenter.initial.stateBadge.classes.includes('state-active'), true);
assert.equal(result.automationControlCenter.initial.queueMeter, '8.420 / 15.000');
assert.equal(result.automationControlCenter.initial.lastErrorHidden, true);
assert.equal(result.automationControlCenter.initial.queueLimitDefault, '15000');
assert.equal(result.automationControlCenter.initial.queueLimitMin, '0');
assert.equal(result.automationControlCenter.initial.queueLimitAcceptsZero, true);
assert.equal(result.automationControlCenter.initial.intervalDefault, '5');
assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']);
assert.equal(result.automationControlCenter.initial.snapshotFrozen, true);
assert.deepEqual(result.automationControlCenter.states, [
{ state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true },
{ state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true },
{ state: 'paused', expectedLabel: 'Pausiert', text: 'Pausiert', classApplied: true },
{ state: 'queue-limited', expectedLabel: 'Queue-Limit erreicht', text: 'Queue-Limit erreicht', classApplied: true },
{ state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true },
{ state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true }
]);
assert.deepEqual(result.automationControlCenter.pausedControls, {
snapshotCalls: 1,
pauseButtonDisabled: false,
pauseButtonText: 'Fortsetzen',
pauseButtonLabel: 'Fortsetzen',
pauseButtonGreen: true,
pauseButtonFits: true,
startDisabled: {
startUploadBtn: true,
startSelectedBtn: true,
reuploadSelectedBtn: true,
retryFailedBtn: true
},
contextStartDisabled: 'true',
contextRetryDisabled: 'true'
});
assert.deepEqual(result.automationControlCenter.pauseResumeActions, {
calls: ['resume', 'pause'],
resumedLabel: 'Abschließen und pausieren',
startDisabledAfterResume: {
startUploadBtn: false,
startSelectedBtn: false,
reuploadSelectedBtn: false,
retryFailedBtn: false
},
pausedLabel: 'Fortsetzen',
pausedLabelEnglish: 'Resume',
configPaused: true
});
assert.deepEqual(result.automationControlCenter.loading, {
visible: true,
busy: 'true',
spinnerVisible: true,
focusInside: true,
backgroundInert: true
});
assert.deepEqual(result.automationControlCenter.completed, {
metricValues: {
found: '4',
filterMatched: '3',
alreadyProcessed: '1',
unavailable: '1',
sizeLimitedJobs: '0',
acceptedFiles: '1',
selectedTargets: '1',
resultingJobs: '1',
availableSlots: '6.580',
deferredFiles: '0'
},
metricCount: 10,
germanMetricLabels: [
'Gefundene Dateien',
'Passend zum Dateifilter',
'Bereits verarbeitet',
'Fehlend, leer oder nicht lesbar',
'Durch Größenlimits ausgeschlossen',
'Akzeptierte Dateien',
'Ausgewählte Ziele',
'Entstehende Upload-Jobs',
'Verfügbare Jobs bis zum Queue-Limit',
'Aktuell zurückzustellende Dateien'
],
errorHidden: true,
actionIds: ['automationTestCloseBtn'],
mutationFree: true
});
assert.deepEqual(result.automationControlCenter.english, {
queueMeter: '8,420 / 15,000',
availableSlots: '6,580',
metricLabels: [
'Files found',
'Matching file filter',
'Already processed',
'Missing, empty, or unreadable',
'Excluded by size limits',
'Accepted files',
'Selected destinations',
'Resulting upload jobs',
'Available jobs before queue limit',
'Files currently deferred'
]
});
assert.deepEqual(result.automationControlCenter.closed, {
hidden: true,
focusReturned: true,
backgroundRestored: true
});
assert.deepEqual(result.automationControlCenter.errorState, {
visible: true,
text: 'Ordnerüberwachung konnte nicht getestet werden.',
secretExposed: false
});
assert.deepEqual(result.automationControlCenter.cancelLoading, {
hidden: true,
enabledAfterCancel: true,
lateResultStayedClosed: true
});
assert.equal(result.automationControlCenterWideLayout.documentOverflow, false);
assert.equal(result.automationControlCenterWideLayout.cardOverflow, false);
assert.equal(result.automationControlCenterWideLayout.metricsOverflow, false);
assert.equal(result.automationControlCenterWideLayout.tabularNumbers, true);
assert.ok(result.automationControlCenterWideLayout.gridTemplateColumns);
assert.equal(result.automationControlCenterNarrowLayout.viewportWidth, 760);
assert.equal(result.automationControlCenterNarrowLayout.documentOverflow, false);
assert.equal(result.automationControlCenterNarrowLayout.cardOverflow, false);
assert.equal(result.automationControlCenterNarrowLayout.metricsOverflow, false);
assert.equal(result.automationControlCenterNarrowLayout.tabularNumbers, true);
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
assert.deepEqual(result.onlineBackupBehavior.initialWarning, {
hidden: false,
@@ -2357,6 +2966,8 @@ app.whenReady().then(async () => {
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].keyLeft - result.onlineBackupLayout.english.rows[0].keyLeft) <= 1);
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].createdLeft - result.onlineBackupLayout.english.rows[0].createdLeft) <= 1);
assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].actionsRight - result.onlineBackupLayout.english.rows[0].actionsRight) <= 1);
assert.equal(result.onlineBackupNarrowLayout.innerWidth, 760);
assert.equal(result.onlineBackupNarrowLayout.narrowMedia, true);
assert.equal(result.onlineBackupNarrowLayout.horizontalOverflow, false);
assert.equal(result.onlineBackupNarrowLayout.rowOverflow, false);
assert.equal(result.onlineBackupNarrowLayout.stacked, true);