diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 411a6d4..23982f4 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -155,6 +155,9 @@ const SettingsScreen: React.FC = () => { const [fileManagerError, setFileManagerError] = useState(''); const [fileManagerSearch, setFileManagerSearch] = useState(''); const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all'); + const [fileManagerSelected, setFileManagerSelected] = useState>(new Set()); + const fileZipPending = useRef(null); // requestId fuer ZIP-Antwort + const [fileZipBusy, setFileZipBusy] = useState(false); const [voiceCloneVisible, setVoiceCloneVisible] = useState(false); const [tempPath, setTempPath] = useState(''); // Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs. @@ -395,9 +398,39 @@ const SettingsScreen: React.FC = () => { const p: any = message.payload || {}; if (p.path) { setFileManagerFiles(prev => prev.filter(f => f.path !== p.path)); + setFileManagerSelected(prev => { + if (!prev.has(p.path)) return prev; + const next = new Set(prev); + next.delete(p.path); + return next; + }); } } + // Datei-Manager: ZIP-Response (Multi-Download) + if (message.type === ('file_zip_response' as any)) { + const p: any = message.payload || {}; + if (p.requestId && p.requestId !== fileZipPending.current) return; // veraltet + fileZipPending.current = null; + setFileZipBusy(false); + if (!p.ok || !p.data) { + ToastAndroid.show('ZIP fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG); + return; + } + // base64 → in Downloads-Ordner schreiben + (async () => { + try { + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const dir = RNFS.DownloadDirectoryPath; + const filePath = `${dir}/aria-files-${ts}.zip`; + await RNFS.writeFile(filePath, p.data, 'base64'); + ToastAndroid.show(`ZIP gespeichert: ${filePath} (${Math.round((p.size||0)/1024)} KB)`, ToastAndroid.LONG); + } catch (e: any) { + ToastAndroid.show('ZIP speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG); + } + })(); + } + // Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen if (message.type === ('xtts_voice_saved' as any)) { const name = (message.payload as any).name as string; @@ -644,64 +677,170 @@ const SettingsScreen: React.FC = () => { Lade... ) : fileManagerError ? ( {fileManagerError} - ) : ( - - {(() => { - let files = fileManagerFiles; - if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria); - else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria); - if (fileManagerSearch) { - const q = fileManagerSearch.toLowerCase(); - files = files.filter(f => f.name.toLowerCase().includes(q)); - } - if (!files.length) { - return Keine Dateien; - } - const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`; - return files.map(f => ( - - - + ) : (() => { + // Visible files (Filter+Suche) + let files = fileManagerFiles; + if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria); + else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria); + if (fileManagerSearch) { + const q = fileManagerSearch.toLowerCase(); + files = files.filter(f => f.name.toLowerCase().includes(q)); + } + const visiblePaths = files.map(f => f.path); + const selectedHere = visiblePaths.filter(p => fileManagerSelected.has(p)); + const allSelected = visiblePaths.length > 0 && selectedHere.length === visiblePaths.length; + const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`; + + const toggleSelectAll = () => { + setFileManagerSelected(prev => { + const next = new Set(prev); + if (allSelected) visiblePaths.forEach(p => next.delete(p)); + else visiblePaths.forEach(p => next.add(p)); + return next; + }); + }; + const toggleOne = (p: string) => { + setFileManagerSelected(prev => { + const next = new Set(prev); + if (next.has(p)) next.delete(p); + else next.add(p); + return next; + }); + }; + const bulkDelete = () => { + const paths = [...fileManagerSelected]; + if (!paths.length) return; + Alert.alert( + `${paths.length} Dateien löschen?`, + 'In allen Chat-Bubbles werden sie als gelöscht markiert.', + [ + { text: 'Abbrechen', style: 'cancel' }, + { text: 'Löschen', style: 'destructive', onPress: () => { + rvs.send('file_delete_batch_request' as any, { paths, requestId: 'batch-' + Date.now() }); + setFileManagerSelected(new Set()); + ToastAndroid.show(`${paths.length} Lösch-Befehle gesendet…`, ToastAndroid.SHORT); + }}, + ], + ); + }; + const bulkDownload = () => { + const paths = [...fileManagerSelected]; + if (!paths.length) return; + // 1 Datei: einfach via file_request (existing pattern). ZIP nur bei 2+. + if (paths.length === 1) { + rvs.send('file_request' as any, { serverPath: paths[0], requestId: 'single-' + Date.now() }); + ToastAndroid.show('Datei wird heruntergeladen…', ToastAndroid.SHORT); + return; + } + const reqId = 'zip-' + Date.now(); + fileZipPending.current = reqId; + setFileZipBusy(true); + rvs.send('file_zip_request' as any, { paths, requestId: reqId }); + ToastAndroid.show(`ZIP wird erstellt (${paths.length} Dateien)…`, ToastAndroid.LONG); + }; + + return ( + <> + {/* Bulk-Bar */} + + + + {allSelected && } + + Alle markieren + + {fileManagerSelected.size > 0 && ( + <> + · + {fileManagerSelected.size} ausgewählt + + {fileZipBusy ? '⏳ ZIP…' : (fileManagerSelected.size > 1 ? '⬇ ZIP' : '⬇ Download')} + + + 🗑 Löschen + + + )} + + + + {!files.length ? ( + Keine Dateien + ) : files.map(f => { + const selected = fileManagerSelected.has(f.path); + return ( + toggleOne(f.path)} + activeOpacity={0.7} + style={{ + backgroundColor: selected ? '#1E2C44' : '#0D0D1A', + padding:12, borderRadius:8, marginBottom:8, + flexDirection:'row', alignItems:'center', gap:8, + borderWidth: selected ? 1 : 0, borderColor:'#0096FF', + }} + > - - {f.fromAria ? 'ARIA' : 'USER'} + {selected && } + + + + + + {f.fromAria ? 'ARIA' : 'USER'} + + + {f.name} + + + {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} - {f.name} - - - {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} - - - { - Alert.alert( - 'Datei löschen?', - `"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`, - [ - { text: 'Abbrechen', style: 'cancel' }, - { text: 'Löschen', style: 'destructive', onPress: () => { - rvs.send('file_delete_request' as any, { path: f.path }); - ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT); - }}, - ], - ); - }} - style={{padding:8}} - > - 🗑 - - - )); - })()} - - )} + { + Alert.alert( + 'Datei löschen?', + `"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { text: 'Löschen', style: 'destructive', onPress: () => { + rvs.send('file_delete_request' as any, { path: f.path }); + ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT); + }}, + ], + ); + }} + style={{padding:8}} + > + 🗑 + + + ); + })} + + + ); + })()} diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 9a6284b..fb4c4d7 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1761,6 +1761,89 @@ class ARIABridge: logger.warning("[rvs] file_list_request: %s", e) return + elif msg_type == "file_delete_batch_request": + # App will mehrere Dateien auf einmal loeschen. + paths = payload.get("paths") or [] + req_id = payload.get("requestId", "") + logger.warning("[rvs] file_delete_batch_request: %d Pfade", len(paths)) + try: + body_bytes = json.dumps({"paths": paths}).encode("utf-8") + req = urllib.request.Request( + "http://localhost:3001/api/files-delete-batch", + data=body_bytes, method="POST", + headers={"Content-Type": "application/json"}, + ) + def _do_delete(): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, resp.read().decode("utf-8", errors="ignore") + except Exception as e: + return None, str(e) + status, body = await asyncio.get_event_loop().run_in_executor(None, _do_delete) + logger.info("[rvs] file_delete_batch result: status=%s", status) + # Server broadcastet file_deleted pro Pfad — App kriegt das via persistente RVS. + # Wir bestaetigen zusaetzlich mit Counts. + try: d = json.loads(body or "{}") + except: d = {} + await self._send_to_rvs({ + "type": "file_delete_batch_response", + "payload": { + "requestId": req_id, + "deleted": len(d.get("deleted", [])), + "errors": d.get("errors", []), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + except Exception as e: + logger.warning("[rvs] file_delete_batch_request: %s", e) + return + + elif msg_type == "file_zip_request": + # App will mehrere Dateien als ZIP. Bridge holt ZIP von Diagnostic + # via HTTP, kodiert base64 und schickt zurueck. Cap auf 30 MB + # ZIP-Groesse damit RVS nicht erstickt. + paths = payload.get("paths") or [] + req_id = payload.get("requestId", "") + logger.warning("[rvs] file_zip_request: %d Pfade (req=%s)", len(paths), req_id) + + def _do_zip(): + try: + body_bytes = json.dumps({"paths": paths}).encode("utf-8") + req = urllib.request.Request( + "http://localhost:3001/api/files-download-zip", + data=body_bytes, method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + if resp.status != 200: + return None, f"HTTP {resp.status}" + data = resp.read() + if len(data) > 30 * 1024 * 1024: + return None, f"ZIP zu gross ({len(data) // (1024*1024)} MB > 30 MB)" + return data, None + except Exception as e: + return None, str(e) + + data, err = await asyncio.get_event_loop().run_in_executor(None, _do_zip) + if err or data is None: + await self._send_to_rvs({ + "type": "file_zip_response", + "payload": {"requestId": req_id, "ok": False, "error": err or "leer"}, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return + import base64 as _b64 + await self._send_to_rvs({ + "type": "file_zip_response", + "payload": { + "requestId": req_id, "ok": True, + "size": len(data), + "data": _b64.b64encode(data).decode("ascii"), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return + elif msg_type == "file_delete_request": # App will eine Datei loeschen — leite an Diagnostic. p = payload.get("path", "") diff --git a/diagnostic/Dockerfile b/diagnostic/Dockerfile index d65f05e..5f039bc 100644 --- a/diagnostic/Dockerfile +++ b/diagnostic/Dockerfile @@ -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 . . diff --git a/diagnostic/index.html b/diagnostic/index.html index a14732c..55f31a2 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -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 + ? `${selectedCount} ausgewählt + + ` + : ''; + if (infoEl) { + infoEl.innerHTML = ` + + · + ${files.length} von ${filesCache.length} Dateien + ${bulkBtns ? '·' + bulkBtns : ''} + `; + } if (!files.length) { listEl.innerHTML = '(Keine Dateien gefunden)'; return; @@ -2843,17 +2883,74 @@ const badge = f.fromAria ? 'ARIA' : 'User'; - return `
+ const checked = filesSelected.has(f.path) ? 'checked' : ''; + const pathEsc = escapeHtml(f.path); + return `
+
${badge}${escapeHtml(f.name)}
${fmtSize(f.size)} · ${fmtDate(f.mtime)}
- +
`; }).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; } diff --git a/diagnostic/server.js b/diagnostic/server.js index 0c91363..8085059 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -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(); }); diff --git a/rvs/server.js b/rvs/server.js index 5a56291..63155da 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -26,6 +26,8 @@ const ALLOWED_TYPES = new Set([ "xtts_import_voice", "xtts_voice_imported", "skill_created", "chat_history_request", "chat_history_response", "chat_cleared", + "file_delete_batch_request", "file_delete_batch_response", + "file_zip_request", "file_zip_response", "xtts_delete_voice", "voice_preload", "voice_ready", "stt_request", "stt_response",