feat: Datei-Manager Multi-Select + Bulk-Download (ZIP) + Bulk-Delete
Diagnostic + App bekommen Mehrfach-Auswahl im Datei-Manager. Mehr als eine
Datei ausgewaehlt → Download als ZIP. Genau eine ausgewaehlt → einzeln.
Bulk-Delete loescht alle markierten in einem Rutsch.
diagnostic/Dockerfile
zip via apk add — fuer das ZIP-Streaming im /api/files-download-zip.
diagnostic/server.js
POST /api/files-download-zip Body: {paths:[...]} → spawnt 'zip -j -q -',
Pipes stdout in Response. Whitelist auf
/shared/uploads/.
POST /api/files-delete-batch Body: {paths:[...]} → loescht alle, broadcastet
file_deleted pro Pfad an Browser + RVS.
diagnostic/index.html
filesSelected Set + Checkbox-UI pro Datei + "Alle markieren". Wenn 2+
ausgewaehlt: POST an /api/files-download-zip, Browser saugt das als
Blob runter. Bei 1: normaler Single-Download.
bridge/aria_bridge.py
file_delete_batch_request → ruft Diagnostic /api/files-delete-batch,
antwortet mit file_delete_batch_response.
file_zip_request {paths,reqId} → ruft Diagnostic /api/files-download-zip,
base64-kodiert, capped auf 30 MB,
sendet file_zip_response.
rvs/server.js
ALLOWED_TYPES: file_delete_batch_request/response, file_zip_request/response.
android/src/screens/SettingsScreen.tsx
fileManagerSelected Set + Checkbox-UI pro Datei + "Alle markieren"-Zeile
oben. Bulk-Bar oben mit count, "⬇ ZIP" / "⬇ Download" (je nach Anzahl),
und "🗑 Löschen". ZIP-Response landet base64 → RNFS in Downloads-Folder
(aria-files-<timestamp>.zip), Toast mit Pfad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+104
-7
@@ -2807,6 +2807,7 @@
|
||||
|
||||
// ── Datei-Manager ──────────────────────────────────────
|
||||
let filesCache = [];
|
||||
const filesSelected = new Set(); // Set of paths
|
||||
|
||||
async function loadFiles() {
|
||||
const listEl = document.getElementById('files-list');
|
||||
@@ -2816,23 +2817,62 @@
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
|
||||
filesCache = d.files || [];
|
||||
// Selection bereinigen — nicht mehr existierende Pfade raus
|
||||
const existing = new Set(filesCache.map(f => f.path));
|
||||
for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p);
|
||||
renderFilesList();
|
||||
} catch (e) {
|
||||
if (listEl) listEl.innerHTML = `🔴 ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilesList() {
|
||||
const listEl = document.getElementById('files-list');
|
||||
const infoEl = document.getElementById('files-info');
|
||||
if (!listEl) return;
|
||||
function getVisibleFiles() {
|
||||
const q = (document.getElementById('files-search').value || '').toLowerCase();
|
||||
const filter = document.getElementById('files-filter').value;
|
||||
let files = filesCache.slice();
|
||||
if (filter === 'aria') files = files.filter(f => f.fromAria);
|
||||
else if (filter === 'user') files = files.filter(f => !f.fromAria);
|
||||
if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
|
||||
if (infoEl) infoEl.textContent = `${files.length} von ${filesCache.length} Dateien`;
|
||||
return files;
|
||||
}
|
||||
|
||||
function toggleFileSelect(path) {
|
||||
if (filesSelected.has(path)) filesSelected.delete(path);
|
||||
else filesSelected.add(path);
|
||||
renderFilesList();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const visible = getVisibleFiles();
|
||||
const allSelected = visible.length > 0 && visible.every(f => filesSelected.has(f.path));
|
||||
if (allSelected) visible.forEach(f => filesSelected.delete(f.path));
|
||||
else visible.forEach(f => filesSelected.add(f.path));
|
||||
renderFilesList();
|
||||
}
|
||||
|
||||
function renderFilesList() {
|
||||
const listEl = document.getElementById('files-list');
|
||||
const infoEl = document.getElementById('files-info');
|
||||
if (!listEl) return;
|
||||
const files = getVisibleFiles();
|
||||
const selectedCount = files.filter(f => filesSelected.has(f.path)).length;
|
||||
const allChecked = files.length > 0 && selectedCount === files.length;
|
||||
const bulkBtns = selectedCount > 0
|
||||
? `<span style="color:#0096FF;font-weight:bold;">${selectedCount} ausgewählt</span>
|
||||
<button class="btn" onclick="downloadSelected()" style="padding:2px 10px;font-size:11px;">⬇ Download ${selectedCount > 1 ? '(ZIP)' : ''}</button>
|
||||
<button class="btn secondary" onclick="deleteSelected()" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Auswahl löschen</button>`
|
||||
: '';
|
||||
if (infoEl) {
|
||||
infoEl.innerHTML = `
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;color:#E0E0F0;">
|
||||
<input type="checkbox" ${allChecked ? 'checked' : ''} onchange="toggleSelectAll()" style="cursor:pointer;">
|
||||
<span>Alle markieren</span>
|
||||
</label>
|
||||
<span style="margin:0 8px;color:#555570;">·</span>
|
||||
<span>${files.length} von ${filesCache.length} Dateien</span>
|
||||
${bulkBtns ? '<span style="margin:0 8px;color:#555570;">·</span>' + bulkBtns : ''}
|
||||
`;
|
||||
}
|
||||
if (!files.length) {
|
||||
listEl.innerHTML = '(Keine Dateien gefunden)';
|
||||
return;
|
||||
@@ -2843,17 +2883,74 @@
|
||||
const badge = f.fromAria
|
||||
? '<span style="background:#0096FF22;color:#0096FF;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">ARIA</span>'
|
||||
: '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">User</span>';
|
||||
return `<div style="padding:8px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:center;">
|
||||
const checked = filesSelected.has(f.path) ? 'checked' : '';
|
||||
const pathEsc = escapeHtml(f.path);
|
||||
return `<div style="padding:8px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:8px;align-items:center;">
|
||||
<input type="checkbox" ${checked} onchange="toggleFileSelect('${pathEsc}')" style="cursor:pointer;flex-shrink:0;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
|
||||
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
|
||||
</div>
|
||||
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen">⬇</button>
|
||||
<button class="btn secondary" onclick="deleteFile('${escapeHtml(f.path)}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
|
||||
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
const paths = [...filesSelected];
|
||||
if (!paths.length) return;
|
||||
if (paths.length === 1) {
|
||||
// Einzelne Datei: normaler Download
|
||||
downloadFile(encodeURIComponent(paths[0]));
|
||||
return;
|
||||
}
|
||||
// Mehrere: POST mit paths-Array, Browser bekommt ZIP-Stream
|
||||
try {
|
||||
const r = await fetch('/api/files-download-zip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || ('HTTP ' + r.status));
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aria-files-${ts}.zip`;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
} catch (e) {
|
||||
alert('ZIP-Download fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const paths = [...filesSelected];
|
||||
if (!paths.length) return;
|
||||
if (!confirm(`${paths.length} Datei(en) wirklich löschen?\n\nIn allen Chat-Bubbles werden sie als gelöscht markiert.`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/files-delete-batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
filesSelected.clear();
|
||||
loadFiles();
|
||||
} else {
|
||||
alert('Bulk-Delete fehlgeschlagen: ' + (d.error || 'unbekannt'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Bulk-Delete fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(encPath) {
|
||||
window.location.href = '/api/files-download?path=' + encPath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user