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:
@@ -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<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 [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 = () => {
|
||||
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
|
||||
) : fileManagerError ? (
|
||||
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
|
||||
) : (
|
||||
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12}}>
|
||||
{(() => {
|
||||
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 <Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>;
|
||||
}
|
||||
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 => (
|
||||
<View key={f.path} style={{
|
||||
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8,
|
||||
flexDirection:'row', alignItems:'center', gap:8,
|
||||
}}>
|
||||
<View style={{flex:1}}>
|
||||
<View style={{flexDirection:'row', alignItems:'center'}}>
|
||||
) : (() => {
|
||||
// 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 */}
|
||||
<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={{
|
||||
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
|
||||
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
|
||||
width:18, height:18, borderRadius:3,
|
||||
borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
|
||||
backgroundColor: selected ? '#0096FF' : 'transparent',
|
||||
alignItems:'center', justifyContent:'center',
|
||||
}}>
|
||||
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
|
||||
{f.fromAria ? 'ARIA' : 'USER'}
|
||||
{selected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}>✓</Text>}
|
||||
</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>
|
||||
</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>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
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}}
|
||||
>
|
||||
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
));
|
||||
})()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
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}}
|
||||
>
|
||||
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
|
||||
Reference in New Issue
Block a user