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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user