// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base. Object.assign(BackupPage.prototype, { renderLocations() { const list = document.getElementById('backup-location-list'); const repoSelect = document.getElementById('backup-snapshot-repo'); if (!list) return; const locs = this.locations?.locations || []; if (!locs.length) { list.innerHTML = `
No backup locations configured yet.
Click Add location above to create one.
`; } else { list.innerHTML = locs.map(l => this.renderLocationRow(l)).join(''); } if (repoSelect) { const cur = repoSelect.value; repoSelect.innerHTML = `` + locs.filter(l => l.enabled).map(l => ``).join(''); if (cur) repoSelect.value = cur; } }, renderLocationRow(l) { // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled. const statusKind = l.enabled && l.password_exists ? 'ready' : l.enabled && !l.password_exists ? 'init' : 'disabled'; const statusMeta = { ready: { icon: '✅', label: 'Ready' }, init: { icon: '⏳', label: 'Initialising' }, disabled: { icon: '⏸', label: 'Disabled' } }[statusKind]; const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0; const expanded = this.expandedLocs.has(l.idx); const size = this.formatBytes(parseInt(l.total_size_bytes) || 0); return `
${this.typeIcon(l.type)} ${this.escape(l.name)} ${this.escape(l.type)} ${this.escape(this.engineDisplayName(l.engine))} ${l.append_only ? 'append-only' : ''} ${statusMeta.icon} ${statusMeta.label} · ${snapCount} backup${snapCount === 1 ? '' : 's'} · ${size}
${expanded ? this.renderLocationDetailsBody(l) : ''}
`; }, typeIcon(type) { const local = ` `; const cloud = ` `; return type === 'local' ? local : cloud; }, renderLocationDetailsBody(l) { const idx = l.idx; const groups = this.locFieldGroups(idx, l.type); const retentionValues = { last: l.custom_retention ? (l.keep_last || '') : '', daily: l.custom_retention ? (l.keep_daily || '') : '', weekly: l.custom_retention ? (l.keep_weekly || '') : '', monthly: l.custom_retention ? (l.keep_monthly || '') : '', yearly: l.custom_retention ? (l.keep_yearly || '') : '' }; // Reuse the app-detail tab design (.tabs-wrapper/.tab-button/.tab-panel // from style.css) so the Locations editor matches the rest of the UI. const tab = (id, emoji, label) => ` `; return `
${tab('connection', '🔗', 'Connection')} ${tab('retention', '♻️', 'Retention')} ${tab('advanced', '⚙️', 'Advanced')}
${this.renderConnectionInner(idx, l.type, l, groups.connection)}
${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
${this.renderLocFields(idx, groups.advanced, l)}
`; }, toggleLocationExpand(idx) { const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`); if (!row) return; const details = row.querySelector('.task-details'); const header = row.querySelector('.task-header'); if (!details) return; const willOpen = !this.expandedLocs.has(idx); if (willOpen) { this.expandedLocs.add(idx); const loc = (this.locations?.locations || []).find(l => l.idx === idx); if (loc) { details.innerHTML = this.renderLocationDetailsBody(loc); this.tagFieldsForSave(details); this.filterEngineSelect(details, loc.type, loc.engine); this.applySshAuthVisibility(details); this.applyPathModeVisibility(details); } this.enhanceEngineDetailsButton(); details.classList.add('show'); row.classList.add('expanded'); if (header) header.setAttribute('aria-expanded', 'true'); } else { this.expandedLocs.delete(idx); details.classList.remove('show'); row.classList.remove('expanded'); if (header) header.setAttribute('aria-expanded', 'false'); } }, refreshInlineTypeFields(idx, type) { const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {}; const groups = this.locFieldGroups(idx, type); const conn = document.getElementById(`backup-location-${idx}-connection`); if (conn) { conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection); this.tagFieldsForSave(conn); } // The Advanced tab's fields are type-dependent too (URI override only // applies to some types), so rebuild it alongside the Connection tab. const adv = document.getElementById(`backup-location-${idx}-advanced`); if (adv) { adv.innerHTML = this.renderLocFields(idx, groups.advanced, { ...loc, type }); this.tagFieldsForSave(adv); } // Re-apply dynamic behaviors across the whole details scope: the engine // select lives in the Advanced tab while SSH-auth / path-mode live in // Connection, so target the shared parent rather than one panel. const scope = (conn || adv)?.closest('.task-details'); if (scope) { this.filterEngineSelect(scope, type, loc.engine); this.applySshAuthVisibility(scope); this.applyPathModeVisibility(scope); } this.enhanceEngineDetailsButton(); }, applySshAuthVisibility(scope) { const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]'); if (!authSelect) return; const isPassword = authSelect.value === 'password'; const passInput = scope.querySelector('input[name$="_SSH_PASS"]'); const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement; if (passGroup) passGroup.style.display = isPassword ? '' : 'none'; // SSH key card is the counterpart: shown for key auth, hidden for password. const keyCard = scope.querySelector('.backup-ssh-key-card'); if (keyCard) keyCard.style.display = isPassword ? 'none' : ''; }, applyPathModeVisibility(scope) { const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]'); if (!modeSelect) return; const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])'); const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement; if (!pathGroup) return; pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none'; }, filterEngineSelect(scope, type, preferred) { const select = scope.querySelector('select[name$="_ENGINE"]'); if (!select) return; const compatible = this.enginesForType(type); if (!compatible.length) return; // Float the system-default engine (CFG_BACKUP_ENGINE) to the top and // tag it "(default)" so it's the obvious pick for new locations. const defaultId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim(); const rank = e => (e.id === defaultId ? 0 : 1); const ordered = [...compatible].sort((a, b) => rank(a) - rank(b)); const want = ordered.find(e => e.id === preferred)?.id || ordered[0].id; select.innerHTML = ordered .map(e => { const label = (e.name || e.id) + (e.id === defaultId ? ' (default)' : ''); return ``; }) .join(''); select.value = want; }, async saveInlineLocation(idx) { await this.saveSection(`location-${idx}`); }, deleteInlineLocation(idx) { const loc = (this.locations?.locations || []).find(l => l.idx === idx); const name = loc?.name || `Location ${idx}`; const modal = document.getElementById('backup-delete-location-modal'); const body = document.getElementById('backup-delete-location-modal-body'); if (!modal || !body) return; body.innerHTML = `

Delete location ${this.escape(name)}?

Backup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.

`; modal.dataset.locIdx = String(idx); modal.classList.add('open'); }, async confirmDeleteLocation() { const modal = document.getElementById('backup-delete-location-modal'); if (!modal) return; const idx = parseInt(modal.dataset.locIdx, 10); this.closeAllModals(); this.expandedLocs.delete(idx); await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); setTimeout(() => this.reloadAfterSave(), 2000); }, async setLocationEnabled(idx, enabled) { const encoded = `CFG_BACKUP_LOC_${idx}_ENABLED=${enabled ? 'true' : 'false'}`; try { if (!window.tasksManager?.router) throw new Error('Task system not available'); await window.tasksManager.router.routeAction('config_update', { changes: `'${encoded.replace(/'/g, "'\\''")}'` }); this.notify(`${enabled ? 'Enabling' : 'Disabling'} this location…`, 'success'); setTimeout(() => this.reloadAfterSave(), 2500); } catch (err) { this.notify(`Save failed: ${err.message || err}`, 'error'); } }, });