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
+193 -54
View File
@@ -155,6 +155,9 @@ const SettingsScreen: React.FC = () => {
const [fileManagerError, setFileManagerError] = useState(''); const [fileManagerError, setFileManagerError] = useState('');
const [fileManagerSearch, setFileManagerSearch] = useState(''); const [fileManagerSearch, setFileManagerSearch] = useState('');
const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all'); const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all');
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
const [fileZipBusy, setFileZipBusy] = useState(false);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false); const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState(''); const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs. // Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -395,9 +398,39 @@ const SettingsScreen: React.FC = () => {
const p: any = message.payload || {}; const p: any = message.payload || {};
if (p.path) { if (p.path) {
setFileManagerFiles(prev => prev.filter(f => f.path !== 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 // Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) { if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string; const name = (message.payload as any).name as string;
@@ -644,64 +677,170 @@ const SettingsScreen: React.FC = () => {
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text> <Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
) : fileManagerError ? ( ) : fileManagerError ? (
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text> <Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
) : ( ) : (() => {
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12}}> // Visible files (Filter+Suche)
{(() => { let files = fileManagerFiles;
let files = fileManagerFiles; if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria); else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria); if (fileManagerSearch) {
if (fileManagerSearch) { const q = fileManagerSearch.toLowerCase();
const q = fileManagerSearch.toLowerCase(); files = files.filter(f => f.name.toLowerCase().includes(q));
files = files.filter(f => f.name.toLowerCase().includes(q)); }
} const visiblePaths = files.map(f => f.path);
if (!files.length) { const selectedHere = visiblePaths.filter(p => fileManagerSelected.has(p));
return <Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>; 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 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 => ( const toggleSelectAll = () => {
<View key={f.path} style={{ setFileManagerSelected(prev => {
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8, const next = new Set(prev);
flexDirection:'row', alignItems:'center', gap:8, if (allSelected) visiblePaths.forEach(p => next.delete(p));
}}> else visiblePaths.forEach(p => next.add(p));
<View style={{flex:1}}> return next;
<View style={{flexDirection:'row', alignItems:'center'}}> });
};
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 */}
<View style={{paddingHorizontal:12, paddingBottom:8, flexDirection:'row', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<TouchableOpacity onPress={toggleSelectAll} style={{flexDirection:'row', alignItems:'center', gap:6, paddingVertical:4}}>
<View style={{
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: allSelected ? '#0096FF' : '#555570',
backgroundColor: allSelected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
{allSelected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
</View>
<Text style={{color:'#E0E0F0', fontSize:13}}>Alle markieren</Text>
</TouchableOpacity>
{fileManagerSelected.size > 0 && (
<>
<Text style={{color:'#555570', fontSize:13}}>·</Text>
<Text style={{color:'#0096FF', fontSize:13, fontWeight:'600'}}>{fileManagerSelected.size} ausgewählt</Text>
<TouchableOpacity
onPress={bulkDownload}
disabled={fileZipBusy}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22', opacity: fileZipBusy ? 0.5 : 1}}
>
<Text style={{color:'#0096FF', fontSize:12}}>{fileZipBusy ? '⏳ ZIP…' : (fileManagerSelected.size > 1 ? '⬇ ZIP' : '⬇ Download')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={bulkDelete}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#FF6B6B22'}}
>
<Text style={{color:'#FF6B6B', fontSize:12}}>🗑 Löschen</Text>
</TouchableOpacity>
</>
)}
</View>
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12, paddingTop:0}}>
{!files.length ? (
<Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>
) : files.map(f => {
const selected = fileManagerSelected.has(f.path);
return (
<TouchableOpacity
key={f.path}
onPress={() => 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',
}}
>
<View style={{ <View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922', width:18, height:18, borderRadius:3,
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6, borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
backgroundColor: selected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}> }}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}> {selected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
{f.fromAria ? 'ARIA' : 'USER'} </View>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center'}}>
<View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
}}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
{f.fromAria ? 'ARIA' : 'USER'}
</Text>
</View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text> </Text>
</View> </View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text> <TouchableOpacity
</View> onPress={() => {
<Text style={{color:'#555570', fontSize:10, marginTop:2}}> Alert.alert(
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} 'Datei löschen?',
</Text> `"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
</View> [
<TouchableOpacity { text: 'Abbrechen', style: 'cancel' },
onPress={() => { { text: 'Löschen', style: 'destructive', onPress: () => {
Alert.alert( rvs.send('file_delete_request' as any, { path: f.path });
'Datei löschen?', ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
`"${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 }); style={{padding:8}}
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT); >
}}, <Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
], </TouchableOpacity>
); </TouchableOpacity>
}} );
style={{padding:8}} })}
> </ScrollView>
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text> </>
</TouchableOpacity> );
</View> })()}
));
})()}
</ScrollView>
)}
</View> </View>
</Modal> </Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
+83
View File
@@ -1761,6 +1761,89 @@ class ARIABridge:
logger.warning("[rvs] file_list_request: %s", e) logger.warning("[rvs] file_list_request: %s", e)
return 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": elif msg_type == "file_delete_request":
# App will eine Datei loeschen — leite an Diagnostic. # App will eine Datei loeschen — leite an Diagnostic.
p = payload.get("path", "") p = payload.get("path", "")
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
RUN apk add --no-cache zip
COPY package.json ./ COPY package.json ./
RUN npm install --production RUN npm install --production
COPY . . COPY . .
+104 -7
View File
@@ -2807,6 +2807,7 @@
// ── Datei-Manager ────────────────────────────────────── // ── Datei-Manager ──────────────────────────────────────
let filesCache = []; let filesCache = [];
const filesSelected = new Set(); // Set of paths
async function loadFiles() { async function loadFiles() {
const listEl = document.getElementById('files-list'); const listEl = document.getElementById('files-list');
@@ -2816,23 +2817,62 @@
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler'); if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
filesCache = d.files || []; 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(); renderFilesList();
} catch (e) { } catch (e) {
if (listEl) listEl.innerHTML = `🔴 ${e.message}`; if (listEl) listEl.innerHTML = `🔴 ${e.message}`;
} }
} }
function renderFilesList() { function getVisibleFiles() {
const listEl = document.getElementById('files-list');
const infoEl = document.getElementById('files-info');
if (!listEl) return;
const q = (document.getElementById('files-search').value || '').toLowerCase(); const q = (document.getElementById('files-search').value || '').toLowerCase();
const filter = document.getElementById('files-filter').value; const filter = document.getElementById('files-filter').value;
let files = filesCache.slice(); let files = filesCache.slice();
if (filter === 'aria') files = files.filter(f => f.fromAria); if (filter === 'aria') files = files.filter(f => f.fromAria);
else if (filter === 'user') 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 (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) { if (!files.length) {
listEl.innerHTML = '(Keine Dateien gefunden)'; listEl.innerHTML = '(Keine Dateien gefunden)';
return; return;
@@ -2843,17 +2883,74 @@
const badge = f.fromAria 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:#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>'; : '<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="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:#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 style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
</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="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>`; </div>`;
}).join(''); }).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) { function downloadFile(encPath) {
window.location.href = '/api/files-download?path=' + 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); fs.createReadStream(safe).pipe(res);
return; 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") { } else if (req.url === "/api/files-delete" && req.method === "POST") {
let body = ""; let body = "";
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); }); req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
+2
View File
@@ -26,6 +26,8 @@ const ALLOWED_TYPES = new Set([
"xtts_import_voice", "xtts_voice_imported", "xtts_import_voice", "xtts_voice_imported",
"skill_created", "skill_created",
"chat_history_request", "chat_history_response", "chat_cleared", "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", "xtts_delete_voice",
"voice_preload", "voice_ready", "voice_preload", "voice_ready",
"stt_request", "stt_response", "stt_request", "stt_response",