feat(app): Versions-Historie pro Datei im App-Datei-Manager
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic). App kann jetzt: - pro Datei via 🕒-Button die Versions-Liste anzeigen - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben - per ⟲ eine alte Version als neue aktive setzen (non-destructive, macht im Backend einen 'restore:'-Commit, die bisherige Version bleibt in der Historie) Drei neue RVS-Message-Type-Paare: file_version_list_request / _response file_version_download_request / _response (base64) file_version_restore_request / _response rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist. bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic (http://localhost:3001/api/files-versions / -version-content / -restore). Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge wrappt die Binary-Antwort als base64 fuer den RVS-Transport. android/src/screens/SettingsScreen.tsx: - State versionsOpen/versionsList/-Loading/-Error - Drei rvs.onMessage-Branches fuer die neuen *_response Types - 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne) - Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject, formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag) - Restore-Button hat Confirm-Alert - Bei file_version_restore_response: list refresh + file-list refresh
This commit is contained in:
@@ -180,6 +180,14 @@ const SettingsScreen: React.FC = () => {
|
||||
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
|
||||
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
|
||||
const [fileZipBusy, setFileZipBusy] = useState(false);
|
||||
// Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git
|
||||
// im diagnostic-Container. Browser-Variante davon laeuft schon, hier App-
|
||||
// Side via RVS-Messages (file_version_list_request/...).
|
||||
const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null);
|
||||
const [versionsList, setVersionsList] = useState<Array<{hash: string; ts: number; subject: string; isCurrent?: boolean}>>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
const [versionsError, setVersionsError] = useState('');
|
||||
const versionDlPending = useRef<string | null>(null); // requestId beim Versions-Download
|
||||
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
||||
const [tempPath, setTempPath] = useState('');
|
||||
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
|
||||
@@ -540,6 +548,74 @@ const SettingsScreen: React.FC = () => {
|
||||
})();
|
||||
}
|
||||
|
||||
// Datei-Manager: Versions-Liste einer Datei
|
||||
if (message.type === ('file_version_list_response' as any)) {
|
||||
const p: any = message.payload || {};
|
||||
setVersionsLoading(false);
|
||||
if (!p.ok) {
|
||||
setVersionsError(p.error || 'Unbekannter Fehler');
|
||||
setVersionsList([]);
|
||||
} else {
|
||||
setVersionsError('');
|
||||
setVersionsList(p.versions || []);
|
||||
}
|
||||
}
|
||||
|
||||
// Datei-Manager: Versions-Inhalt (Download einer alten Version)
|
||||
if (message.type === ('file_version_download_response' as any)) {
|
||||
const p: any = message.payload || {};
|
||||
if (p.requestId && p.requestId !== versionDlPending.current) return;
|
||||
versionDlPending.current = null;
|
||||
if (!p.ok) {
|
||||
ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
|
||||
return;
|
||||
}
|
||||
// base64 → Downloads-Ordner. Hash als Suffix damit Original nicht
|
||||
// ueberschrieben wird wenn beide Versionen nebeneinander vorliegen
|
||||
// sollen.
|
||||
(async () => {
|
||||
try {
|
||||
const baseName = (p.name as string) || 'aria-version';
|
||||
const shortHash = (p.hash as string || '').slice(0, 7);
|
||||
const dot = baseName.lastIndexOf('.');
|
||||
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
|
||||
const ext = dot > 0 ? baseName.slice(dot) : '';
|
||||
const dir = RNFS.DownloadDirectoryPath;
|
||||
let target = `${dir}/${stem}@${shortHash}${ext}`;
|
||||
let i = 1;
|
||||
while (await RNFS.exists(target)) {
|
||||
target = `${dir}/${stem}@${shortHash}_${i}${ext}`;
|
||||
i++;
|
||||
}
|
||||
await RNFS.writeFile(target, p.base64, 'base64');
|
||||
const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024);
|
||||
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
|
||||
} catch (e: any) {
|
||||
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Datei-Manager: Restore-Bestaetigung
|
||||
if (message.type === ('file_version_restore_response' as any)) {
|
||||
const p: any = message.payload || {};
|
||||
if (!p.ok) {
|
||||
ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
|
||||
return;
|
||||
}
|
||||
ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT);
|
||||
// Versions-Liste neu laden damit der neue restore-Commit auftaucht
|
||||
if (versionsOpen) {
|
||||
setVersionsLoading(true);
|
||||
rvs.send('file_version_list_request' as any, { path: versionsOpen.path });
|
||||
}
|
||||
// File-Liste auch refreshen (mtime hat sich geaendert)
|
||||
if (fileManagerOpen) {
|
||||
setFileManagerLoading(true);
|
||||
rvs.send('file_list_request' as any, {});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -964,6 +1040,20 @@ const SettingsScreen: React.FC = () => {
|
||||
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// path-relativ-zu-uploads = nur der Dateiname,
|
||||
// weil der File-Manager-Bereich flach ist
|
||||
setVersionsOpen({name: f.name, path: f.name});
|
||||
setVersionsList([]);
|
||||
setVersionsError('');
|
||||
setVersionsLoading(true);
|
||||
rvs.send('file_version_list_request' as any, { path: f.name });
|
||||
}}
|
||||
style={{padding:8}}
|
||||
>
|
||||
<Text style={{color:'#0096FF', fontSize:18}}>🕒</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
@@ -991,6 +1081,110 @@ const SettingsScreen: React.FC = () => {
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */}
|
||||
<Modal
|
||||
visible={versionsOpen !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setVersionsOpen(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'center', alignItems:'center'}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setVersionsOpen(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={() => {}}
|
||||
style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}}
|
||||
>
|
||||
<View style={{padding:12, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center'}}>
|
||||
<Text style={{color:'#E0E0F0', fontSize:13, fontWeight:'bold', flex:1}} numberOfLines={1}>
|
||||
Versionen — {versionsOpen?.name || ''}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setVersionsOpen(null)} style={{padding:6}}>
|
||||
<Text style={{color:'#888', fontSize:14}}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{maxHeight:'85%'}} contentContainerStyle={{padding:8}}>
|
||||
{versionsLoading && (
|
||||
<Text style={{color:'#888', textAlign:'center', padding:20}}>Lade...</Text>
|
||||
)}
|
||||
{!!versionsError && (
|
||||
<Text style={{color:'#FF6B6B', padding:20}}>{versionsError}</Text>
|
||||
)}
|
||||
{!versionsLoading && !versionsError && versionsList.length === 0 && (
|
||||
<Text style={{color:'#888', textAlign:'center', padding:20}}>
|
||||
Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index).
|
||||
</Text>
|
||||
)}
|
||||
{versionsList.map(v => (
|
||||
<View key={v.hash} style={{padding:10, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center', gap:8}}>
|
||||
<View style={{flex:1}}>
|
||||
<View style={{flexDirection:'row', alignItems:'center', gap:6}}>
|
||||
{v.isCurrent && (
|
||||
<View style={{backgroundColor:'#34C75922', paddingHorizontal:6, paddingVertical:1, borderRadius:3}}>
|
||||
<Text style={{color:'#34C759', fontSize:9}}>AKTIV</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={{color:'#0096FF', fontSize:11, fontFamily:'monospace'}}>
|
||||
{v.hash.slice(0,7)}
|
||||
</Text>
|
||||
<Text style={{color:'#888', fontSize:11, flex:1}} numberOfLines={1}>
|
||||
{v.subject || ''}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
|
||||
{new Date(v.ts).toLocaleString('de-DE')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!versionsOpen) return;
|
||||
const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000);
|
||||
versionDlPending.current = reqId;
|
||||
rvs.send('file_version_download_request' as any, {
|
||||
path: versionsOpen.path,
|
||||
hash: v.hash,
|
||||
requestId: reqId,
|
||||
});
|
||||
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
|
||||
}}
|
||||
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}}
|
||||
>
|
||||
<Text style={{color:'#0096FF', fontSize:11}}>⬇</Text>
|
||||
</TouchableOpacity>
|
||||
{!v.isCurrent && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!versionsOpen) return;
|
||||
Alert.alert(
|
||||
'Version aktiv setzen?',
|
||||
`Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{ text: 'Restore', onPress: () => {
|
||||
rvs.send('file_version_restore_request' as any, {
|
||||
path: versionsOpen.path,
|
||||
hash: v.hash,
|
||||
});
|
||||
ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT);
|
||||
}},
|
||||
],
|
||||
);
|
||||
}}
|
||||
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}}
|
||||
>
|
||||
<Text style={{color:'#fff', fontSize:11}}>⟲</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
|
||||
@@ -2405,6 +2405,129 @@ class ARIABridge:
|
||||
logger.warning("[rvs] file_delete_request: %s", e)
|
||||
return
|
||||
|
||||
elif msg_type == "file_version_list_request":
|
||||
# Versions-Historie einer Datei (App-Side Dateimanager).
|
||||
# Pfad ist relativ-zu-/shared/uploads, kommt vom App-File-Manager
|
||||
# der eh nur diesen flachen Bereich anzeigt. Diagnostic hat die
|
||||
# git-Logik — wir proxien.
|
||||
req_path = payload.get("path", "")
|
||||
logger.info("[rvs] file_version_list_request: %s", req_path)
|
||||
try:
|
||||
qs = urllib.parse.urlencode({"path": req_path})
|
||||
req = urllib.request.Request(
|
||||
f"http://localhost:3001/api/files-versions?{qs}",
|
||||
method="GET",
|
||||
)
|
||||
def _do_list():
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", errors="ignore"))
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
d = await asyncio.get_event_loop().run_in_executor(None, _do_list)
|
||||
await self._send_to_rvs({
|
||||
"type": "file_version_list_response",
|
||||
"payload": d,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] file_version_list_request: %s", e)
|
||||
return
|
||||
|
||||
elif msg_type == "file_version_download_request":
|
||||
# Inhalt einer alten Version holen, base64 zurueck. Diagnostic
|
||||
# liefert Binary, wir wrappen als base64 in der Response damit
|
||||
# die App's RVS-WS damit umgehen kann.
|
||||
req_path = payload.get("path", "")
|
||||
req_hash = payload.get("hash", "")
|
||||
req_id = payload.get("requestId", "")
|
||||
logger.info("[rvs] file_version_download_request: %s @ %s",
|
||||
req_path, req_hash[:7])
|
||||
try:
|
||||
qs = urllib.parse.urlencode({"path": req_path, "hash": req_hash})
|
||||
req = urllib.request.Request(
|
||||
f"http://localhost:3001/api/files-version-content?{qs}",
|
||||
method="GET",
|
||||
)
|
||||
def _do_dl():
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
except Exception as e:
|
||||
return None, str(e).encode("utf-8")
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_dl)
|
||||
if status == 200 and isinstance(body, (bytes, bytearray)):
|
||||
await self._send_to_rvs({
|
||||
"type": "file_version_download_response",
|
||||
"payload": {
|
||||
"ok": True,
|
||||
"requestId": req_id,
|
||||
"path": req_path,
|
||||
"hash": req_hash,
|
||||
"base64": base64.b64encode(body).decode("ascii"),
|
||||
"size": len(body),
|
||||
"name": (req_path.rsplit("/", 1)[-1] or "file"),
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
else:
|
||||
err = body.decode("utf-8", "ignore") if isinstance(body, (bytes, bytearray)) else str(body)
|
||||
await self._send_to_rvs({
|
||||
"type": "file_version_download_response",
|
||||
"payload": {
|
||||
"ok": False,
|
||||
"requestId": req_id,
|
||||
"path": req_path,
|
||||
"hash": req_hash,
|
||||
"error": f"HTTP {status}: {err[:200]}",
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] file_version_download_request: %s", e)
|
||||
return
|
||||
|
||||
elif msg_type == "file_version_restore_request":
|
||||
# Eine Version als neue aktive setzen — non-destructive
|
||||
# (diagnostic schreibt den alten Inhalt + macht einen neuen Commit).
|
||||
req_path = payload.get("path", "")
|
||||
req_hash = payload.get("hash", "")
|
||||
logger.warning("[rvs] file_version_restore_request: %s <- %s",
|
||||
req_path, req_hash[:7])
|
||||
try:
|
||||
body_bytes = json.dumps({"path": req_path, "hash": req_hash}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:3001/api/files-version-restore",
|
||||
data=body_bytes,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
def _do_restore():
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.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_restore)
|
||||
try:
|
||||
parsed = json.loads(body) if body else {"ok": False, "error": "leer"}
|
||||
except Exception:
|
||||
parsed = {"ok": False, "error": body[:200]}
|
||||
if status != 200 and "ok" not in parsed:
|
||||
parsed = {"ok": False, "error": f"HTTP {status}"}
|
||||
await self._send_to_rvs({
|
||||
"type": "file_version_restore_response",
|
||||
"payload": parsed,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] file_version_restore_request: %s", e)
|
||||
return
|
||||
|
||||
elif msg_type == "location_update":
|
||||
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
|
||||
# /shared/state/location.json geschrieben, damit Watcher-Trigger
|
||||
|
||||
@@ -42,6 +42,11 @@ const ALLOWED_TYPES = new Set([
|
||||
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
|
||||
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
|
||||
"stt_partial", "stt_endpoint", "stt_stream_done",
|
||||
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
|
||||
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
|
||||
"file_version_list_request", "file_version_list_response",
|
||||
"file_version_download_request", "file_version_download_response",
|
||||
"file_version_restore_request", "file_version_restore_response",
|
||||
"service_status",
|
||||
"config_request",
|
||||
"flux_request", "flux_response",
|
||||
|
||||
Reference in New Issue
Block a user