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:
2026-05-11 23:30:40 +02:00
parent 0ec4b00879
commit 3ae9e19524
6 changed files with 455 additions and 61 deletions
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine
WORKDIR /app
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
RUN apk add --no-cache zip
COPY package.json ./
RUN npm install --production
COPY . .
+104 -7
View File
@@ -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;
}
+71
View File
@@ -1361,6 +1361,77 @@ const server = http.createServer((req, res) => {
});
fs.createReadStream(safe).pipe(res);
return;
} else if (req.url === "/api/files-download-zip" && req.method === "POST") {
// Multi-Datei-Download als ZIP. Body: {paths: ["/shared/uploads/...", ...]}.
// Streamt zip stdout direkt in die Response.
let body = "";
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
let paths = [];
try { paths = (JSON.parse(body || "{}").paths || []); } catch { paths = []; }
// Whitelist: nur /shared/uploads/, existieren muessen sie
paths = paths
.map(p => path.resolve(String(p)))
.filter(p => p.startsWith("/shared/uploads/") && fs.existsSync(p));
if (!paths.length) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Keine gueltigen Pfade" }));
return;
}
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const fname = `aria-files-${ts}.zip`;
res.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fname}"`,
});
// zip -j: junk paths (Dateien ohne Verzeichnisstruktur ablegen)
const { spawn } = require("child_process");
const zip = spawn("zip", ["-j", "-q", "-", ...paths]);
zip.stdout.pipe(res);
let stderr = "";
zip.stderr.on("data", d => stderr += d.toString());
zip.on("close", code => {
if (code !== 0 && code !== 12) {
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
}
});
req.on("close", () => { if (!zip.killed) zip.kill("SIGTERM"); });
});
return;
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
let paths = (JSON.parse(body || "{}").paths || []);
paths = paths
.map(p => path.resolve(String(p)))
.filter(p => p.startsWith("/shared/uploads/"));
const deleted = [];
const errors = [];
for (const p of paths) {
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
deleted.push(p);
broadcast({ type: "file_deleted", path: p });
sendToRVS_raw({ type: "file_deleted", payload: { path: p }, timestamp: Date.now() });
try {
fs.appendFileSync("/shared/config/chat_backup.jsonl",
JSON.stringify({ type: "file_deleted", path: p, ts: Date.now(), by: "user" }) + "\n");
} catch {}
} catch (e) {
errors.push({ path: p, error: e.message });
}
}
log("info", "server", `Bulk-Delete: ${deleted.length} OK, ${errors.length} Fehler`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, deleted, errors }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/files-delete" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });