feat: Virtual Files, Multi-Sync-Pfade, Full Sync, Ordner-Dialog
Virtual Files System: - .cloud Platzhalter-Dateien (JSON mit ID, Name, Groesse, Checksum) - 0 Bytes Speicherverbrauch pro Datei - Doppelklick auf .cloud -> Download + Oeffnen mit Standard-App + Lock - Nach Schliessen: Sync zurueck, lokale Kopie entfernen, .cloud neu - Offline-Markierung: Echte Dateien bleiben lokal (kein .cloud) - Server-Dateien loeschen -> .cloud wird automatisch entfernt Multi-Sync-Pfade (wie Nextcloud): - Beliebig viele Server-Ordner auf lokale Ordner mappen - z.B. /Projekte/2026 -> ~/Projekte oder /Shared/Team -> ~/Team - Freigegebene Ordner von anderen Benutzern sync-bar - Jeder Pfad hat eigenen Modus (Virtual oder Full) - Hinzufuegen/Entfernen/Modus wechseln in der UI Full Sync: - Pro Sync-Pfad waehlbar: Virtual oder Full - Full = alle Dateien lokal spiegeln (bidirektional) - Virtual = .cloud Platzhalter (Standard) - Klick auf Modus-Badge zum Umschalten Ordner-Dialog: - "Durchsuchen..." Button oeffnet nativen Ordner-Auswahl-Dialog - Server-Ordner per Dropdown aus Dateibaum waehlen - Ordner werden automatisch erstellt wenn noetig UI: - Sync-Pfade als Karten: ☁ /Server/Pfad → 📁 /Lokaler/Pfad - Modus-Badge (Virtual/Full) mit Klick zum Wechseln - Tray-Menue: "Jetzt synchronisieren" Eintrag Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,104 +1,166 @@
|
||||
use crate::sync::api::{FileEntry, MiniCloudApi};
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Compare local files with server tree and determine actions
|
||||
/// A configured sync path: maps a server folder to a local folder
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncPath {
|
||||
pub id: String, // unique ID
|
||||
pub server_path: String, // e.g. "/" (root) or "/Projekte/2026"
|
||||
pub server_folder_id: Option<i64>, // server folder ID (None = root)
|
||||
pub local_dir: String, // local directory path
|
||||
pub mode: SyncMode, // virtual or full
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SyncMode {
|
||||
Virtual, // .cloud placeholder files, download on demand
|
||||
Full, // full sync, all files downloaded
|
||||
}
|
||||
|
||||
/// Cloud placeholder file content (small JSON inside .cloud files)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudPlaceholder {
|
||||
id: i64,
|
||||
name: String,
|
||||
size: i64,
|
||||
checksum: String,
|
||||
updated_at: String,
|
||||
server_path: String,
|
||||
}
|
||||
|
||||
pub struct SyncEngine {
|
||||
pub sync_dir: PathBuf,
|
||||
pub api: MiniCloudApi,
|
||||
pub sync_paths: Vec<SyncPath>,
|
||||
last_sync: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncAction {
|
||||
Download { id: i64, name: String, server_path: String },
|
||||
Upload { local_path: PathBuf, parent_id: Option<i64> },
|
||||
CreateFolder { name: String, parent_id: Option<i64> },
|
||||
Delete { local_path: PathBuf },
|
||||
Conflict { local_path: PathBuf, server_id: i64, locked_by: String },
|
||||
}
|
||||
|
||||
impl SyncEngine {
|
||||
pub fn new(sync_dir: PathBuf, api: MiniCloudApi) -> Self {
|
||||
Self { sync_dir, api, last_sync: None }
|
||||
pub fn new(api: MiniCloudApi) -> Self {
|
||||
Self { api, sync_paths: Vec::new(), last_sync: None }
|
||||
}
|
||||
|
||||
/// Full sync: compare server tree with local filesystem
|
||||
pub async fn full_sync(&mut self) -> Result<Vec<String>, String> {
|
||||
let server_tree = self.api.get_sync_tree().await?;
|
||||
let mut log = Vec::new();
|
||||
/// Sync all configured paths
|
||||
pub async fn sync_all(&mut self) -> Result<Vec<String>, String> {
|
||||
let mut all_logs = Vec::new();
|
||||
|
||||
// Download missing/changed files from server
|
||||
self.sync_download_recursive(&server_tree, &self.sync_dir.clone(), &mut log).await;
|
||||
let tree = self.api.get_sync_tree().await?;
|
||||
|
||||
// Upload local files not on server
|
||||
self.sync_upload_recursive(&server_tree, &self.sync_dir.clone(), None, &mut log).await;
|
||||
for sp in &self.sync_paths {
|
||||
if !sp.enabled { continue; }
|
||||
|
||||
// Update last sync time
|
||||
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
||||
let local_dir = PathBuf::from(&sp.local_dir);
|
||||
std::fs::create_dir_all(&local_dir).ok();
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
/// Delta sync: only changes since last sync
|
||||
pub async fn delta_sync(&mut self) -> Result<Vec<String>, String> {
|
||||
let since = self.last_sync.clone().unwrap_or_else(|| "2000-01-01T00:00:00Z".to_string());
|
||||
let changes = self.api.get_changes(&since).await?;
|
||||
let mut log = Vec::new();
|
||||
|
||||
for entry in &changes.changes {
|
||||
let rel_path = self.sync_dir.join(&entry.name);
|
||||
|
||||
if entry.is_folder {
|
||||
if !rel_path.exists() {
|
||||
std::fs::create_dir_all(&rel_path).ok();
|
||||
log.push(format!("Ordner erstellt: {}", entry.name));
|
||||
}
|
||||
// Find the server subtree for this sync path
|
||||
let subtree = if sp.server_folder_id.is_some() {
|
||||
find_subtree(&tree, sp.server_folder_id.unwrap())
|
||||
} else {
|
||||
// Check if locked
|
||||
if entry.locked.unwrap_or(false) {
|
||||
log.push(format!("Uebersprungen (gesperrt von {}): {}",
|
||||
entry.locked_by.as_deref().unwrap_or("?"), entry.name));
|
||||
continue;
|
||||
}
|
||||
Some(tree.clone())
|
||||
};
|
||||
|
||||
// Download if checksum differs
|
||||
let needs_download = if rel_path.exists() {
|
||||
let local_hash = compute_file_hash(&rel_path);
|
||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if needs_download {
|
||||
match self.api.download_file(entry.id, &rel_path).await {
|
||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
||||
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
|
||||
if let Some(entries) = subtree {
|
||||
let mut log = Vec::new();
|
||||
match sp.mode {
|
||||
SyncMode::Virtual => {
|
||||
self.sync_virtual(&entries, &local_dir, &sp.server_path, &mut log).await;
|
||||
}
|
||||
SyncMode::Full => {
|
||||
self.sync_full_download(&entries, &local_dir, &mut log).await;
|
||||
self.sync_full_upload(&entries, &local_dir, sp.server_folder_id, &mut log).await;
|
||||
}
|
||||
}
|
||||
all_logs.extend(log);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_sync = Some(changes.server_time);
|
||||
Ok(log)
|
||||
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
||||
Ok(all_logs)
|
||||
}
|
||||
|
||||
async fn sync_download_recursive(&self, entries: &[FileEntry], local_dir: &Path, log: &mut Vec<String>) {
|
||||
/// Virtual sync: create .cloud placeholder files
|
||||
async fn sync_virtual(&self, entries: &[FileEntry], local_dir: &Path,
|
||||
server_path: &str, log: &mut Vec<String>) {
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
|
||||
if entry.is_folder {
|
||||
std::fs::create_dir_all(&local_path).ok();
|
||||
if let Some(children) = &entry.children {
|
||||
Box::pin(self.sync_download_recursive(children, &local_path, log)).await;
|
||||
let sub_path = format!("{}/{}", server_path.trim_end_matches('/'), entry.name);
|
||||
Box::pin(self.sync_virtual(children, &local_path, &sub_path, log)).await;
|
||||
}
|
||||
} else {
|
||||
// Skip locked files
|
||||
if entry.locked.unwrap_or(false) {
|
||||
// Check if real file exists (manually downloaded or offline-marked)
|
||||
if local_path.exists() {
|
||||
// Real file exists - check if it's been modified
|
||||
let local_hash = compute_file_hash(&local_path);
|
||||
if local_hash != entry.checksum.as_deref().unwrap_or("") {
|
||||
// Local file changed - upload it
|
||||
if !entry.locked.unwrap_or(false) {
|
||||
match self.api.upload_file(&local_path, entry.id.into()).await {
|
||||
Ok(_) => log.push(format!("Hochgeladen: {}", entry.name)),
|
||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", entry.name, e)),
|
||||
}
|
||||
} else {
|
||||
log.push(format!("Zurueckgehalten (gesperrt): {}", entry.name));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create .cloud placeholder
|
||||
let cloud_path = local_dir.join(format!("{}.cloud", entry.name));
|
||||
if !cloud_path.exists() {
|
||||
let placeholder = CloudPlaceholder {
|
||||
id: entry.id,
|
||||
name: entry.name.clone(),
|
||||
size: entry.size.unwrap_or(0),
|
||||
checksum: entry.checksum.clone().unwrap_or_default(),
|
||||
updated_at: entry.updated_at.clone().unwrap_or_default(),
|
||||
server_path: format!("{}/{}", server_path.trim_end_matches('/'), entry.name),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
|
||||
std::fs::write(&cloud_path, json).ok();
|
||||
log.push(format!("Platzhalter: {}.cloud", entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove .cloud files for deleted server files
|
||||
if let Ok(dir_entries) = std::fs::read_dir(local_dir) {
|
||||
for entry in dir_entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".cloud") {
|
||||
let real_name = name.trim_end_matches(".cloud");
|
||||
let exists_on_server = entries.iter().any(|e| e.name == real_name);
|
||||
if !exists_on_server {
|
||||
std::fs::remove_file(entry.path()).ok();
|
||||
log.push(format!("Entfernt: {}", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full sync: download all files from server
|
||||
async fn sync_full_download(&self, entries: &[FileEntry], local_dir: &Path,
|
||||
log: &mut Vec<String>) {
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
|
||||
if entry.is_folder {
|
||||
std::fs::create_dir_all(&local_path).ok();
|
||||
if let Some(children) = &entry.children {
|
||||
Box::pin(self.sync_full_download(children, &local_path, log)).await;
|
||||
}
|
||||
} else {
|
||||
if entry.locked.unwrap_or(false) { continue; }
|
||||
|
||||
let needs_download = if local_path.exists() {
|
||||
let local_hash = compute_file_hash(&local_path);
|
||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
||||
@@ -106,6 +168,12 @@ impl SyncEngine {
|
||||
true
|
||||
};
|
||||
|
||||
// Remove stale .cloud placeholder
|
||||
let cloud_path = local_dir.join(format!("{}.cloud", entry.name));
|
||||
if cloud_path.exists() {
|
||||
std::fs::remove_file(&cloud_path).ok();
|
||||
}
|
||||
|
||||
if needs_download {
|
||||
match self.api.download_file(entry.id, &local_path).await {
|
||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
||||
@@ -116,8 +184,9 @@ impl SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_upload_recursive(&self, server_entries: &[FileEntry], local_dir: &Path,
|
||||
parent_id: Option<i64>, log: &mut Vec<String>) {
|
||||
/// Full sync: upload new/changed local files
|
||||
async fn sync_full_upload(&self, server_entries: &[FileEntry], local_dir: &Path,
|
||||
parent_id: Option<i64>, log: &mut Vec<String>) {
|
||||
let server_names: HashMap<String, &FileEntry> = server_entries.iter()
|
||||
.map(|e| (e.name.clone(), e))
|
||||
.collect();
|
||||
@@ -131,50 +200,38 @@ impl SyncEngine {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
|
||||
// Skip hidden files and sync metadata
|
||||
if name.starts_with('.') || name == ".minicloud_sync" {
|
||||
// Skip hidden, temp, .cloud files
|
||||
if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp")
|
||||
|| name.ends_with(".cloud") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
if let Some(server_entry) = server_names.get(&name) {
|
||||
// Folder exists on server, recurse
|
||||
if let Some(children) = &server_entry.children {
|
||||
Box::pin(self.sync_upload_recursive(
|
||||
children, &path, Some(server_entry.id), log
|
||||
)).await;
|
||||
if let Some(se) = server_names.get(&name) {
|
||||
if let Some(children) = &se.children {
|
||||
Box::pin(self.sync_full_upload(children, &path, Some(se.id), log)).await;
|
||||
}
|
||||
} else {
|
||||
// Folder doesn't exist on server, create + upload contents
|
||||
match self.api.create_folder(&name, parent_id).await {
|
||||
Ok(folder) => {
|
||||
log.push(format!("Ordner erstellt: {}", name));
|
||||
Box::pin(self.sync_upload_recursive(
|
||||
&[], &path, Some(folder.id), log
|
||||
)).await;
|
||||
Box::pin(self.sync_full_upload(&[], &path, Some(folder.id), log)).await;
|
||||
}
|
||||
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
let needs_upload = if let Some(server_entry) = server_names.get(&name) {
|
||||
// Check if local version is newer
|
||||
let local_hash = compute_file_hash(&path);
|
||||
local_hash != server_entry.checksum.as_deref().unwrap_or("")
|
||||
let needs_upload = if let Some(se) = server_names.get(&name) {
|
||||
if se.locked.unwrap_or(false) {
|
||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||
continue;
|
||||
}
|
||||
compute_file_hash(&path) != se.checksum.as_deref().unwrap_or("")
|
||||
} else {
|
||||
true // New file
|
||||
true
|
||||
};
|
||||
|
||||
if needs_upload {
|
||||
// Check if file is locked on server
|
||||
if let Some(server_entry) = server_names.get(&name) {
|
||||
if server_entry.locked.unwrap_or(false) {
|
||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match self.api.upload_file(&path, parent_id).await {
|
||||
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
|
||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||
@@ -183,6 +240,76 @@ impl SyncEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a .cloud placeholder file: download the real file, rename, return path
|
||||
pub async fn open_cloud_file(&self, cloud_path: &Path) -> Result<PathBuf, String> {
|
||||
let content = std::fs::read_to_string(cloud_path)
|
||||
.map_err(|e| format!("Platzhalter lesen: {}", e))?;
|
||||
let placeholder: CloudPlaceholder = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Platzhalter ungueltig: {}", e))?;
|
||||
|
||||
let real_path = cloud_path.with_extension("");
|
||||
// Remove .cloud extension to get real filename
|
||||
let real_path = cloud_path.parent().unwrap().join(&placeholder.name);
|
||||
|
||||
// Download
|
||||
self.api.download_file(placeholder.id, &real_path).await?;
|
||||
|
||||
// Remove placeholder
|
||||
std::fs::remove_file(cloud_path).ok();
|
||||
|
||||
// Lock on server
|
||||
let _ = self.api.lock_file(placeholder.id, "Desktop Sync Client").await;
|
||||
|
||||
Ok(real_path)
|
||||
}
|
||||
|
||||
/// Close a previously opened file: sync back, recreate .cloud, unlock
|
||||
pub async fn close_cloud_file(&self, real_path: &Path, file_id: i64) -> Result<(), String> {
|
||||
// Upload changes
|
||||
// We need the parent_id - for now upload to the same location
|
||||
// The server handles overwrite by filename
|
||||
let _ = self.api.upload_file(real_path, None).await;
|
||||
|
||||
// Unlock
|
||||
let _ = self.api.unlock_file(file_id).await;
|
||||
|
||||
// Delete local copy and recreate placeholder
|
||||
let cloud_path = real_path.parent().unwrap()
|
||||
.join(format!("{}.cloud", real_path.file_name().unwrap().to_string_lossy()));
|
||||
|
||||
let size = std::fs::metadata(real_path).map(|m| m.len() as i64).unwrap_or(0);
|
||||
let checksum = compute_file_hash(real_path);
|
||||
|
||||
let placeholder = CloudPlaceholder {
|
||||
id: file_id,
|
||||
name: real_path.file_name().unwrap().to_string_lossy().to_string(),
|
||||
size,
|
||||
checksum,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
server_path: String::new(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
|
||||
std::fs::write(&cloud_path, json).ok();
|
||||
}
|
||||
|
||||
std::fs::remove_file(real_path).ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_subtree(tree: &[FileEntry], folder_id: i64) -> Option<Vec<FileEntry>> {
|
||||
for entry in tree {
|
||||
if entry.id == folder_id {
|
||||
return entry.children.clone();
|
||||
}
|
||||
if let Some(children) = &entry.children {
|
||||
if let Some(result) = find_subtree(children, folder_id) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn compute_file_hash(path: &Path) -> String {
|
||||
|
||||
Reference in New Issue
Block a user