feat(cloud-files): Geteilte Ordner + Rechtsklick-Menue

Backend:
- /api/sync/tree liefert jetzt {tree, shared} - shared enthaelt alle
  Dateien die MIT dem Benutzer geteilt wurden (FilePermission), nur
  Top-Level-Shares, mit Owner-Name im Anzeigenamen
- updated_at zusaetzlich als modified_at im Response fuer Client-
  Kompatibilitaet

Client:
- fetch_remote_entries merged Shared-Subtree unter virtuellem Ordner
  "Geteilt mit mir" (synthetische ID -1) in den Mount-Point
- modified_at faellt auf updated_at zurueck, falls nicht vorhanden

Kontextmenue:
- Neue HKCU-Registry-Eintraege fuer "Immer offline verfuegbar" und
  "Speicher freigeben", AppliesTo filtert auf Mount-Pfad, sodass die
  Verben nur bei Dateien unterhalb des Sync-Ordners erscheinen
- Aufruf der eigenen .exe mit --pin / --unpin <file>
- handle_cli_shortcuts fuehrt die Aktion aus und beendet sofort,
  ohne die UI/Tray/Single-Instance-Logik anzustossen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-23 11:15:04 +02:00
parent 4026defe79
commit 78cfbf1ad3
3 changed files with 169 additions and 17 deletions

View File

@ -1254,32 +1254,79 @@ def list_locks():
@api_bp.route('/sync/tree', methods=['GET']) @api_bp.route('/sync/tree', methods=['GET'])
@token_required @token_required
def sync_tree(): def sync_tree():
"""Returns complete file tree with checksums for sync clients.""" """Returns complete file tree with checksums for sync clients.
Includes both files owned by the user (under 'tree') and files
shared WITH the user (as a virtual 'Geteilt mit mir' folder under
'shared'). The client merges both.
"""
user = request.current_user user = request.current_user
def _entry(f):
entry = {
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
'modified_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
return entry
def _build_tree(parent_id): def _build_tree(parent_id):
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\ files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all() .order_by(File.is_folder.desc(), File.name).all()
result = [] result = []
for f in files: for f in files:
entry = { e = _entry(f)
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
if f.is_folder: if f.is_folder:
entry['children'] = _build_tree(f.id) e['children'] = _build_tree(f.id)
result.append(entry) result.append(e)
return result return result
return jsonify({'tree': _build_tree(None)}), 200 def _build_shared_children(parent_id):
files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all()
out = []
for f in files:
e = _entry(f)
if f.is_folder:
e['children'] = _build_shared_children(f.id)
out.append(e)
return out
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
shared_roots = []
seen = set()
for perm in shared_perms:
f = perm.file
if not f or f.is_trashed or f.id in seen:
continue
seen.add(f.id)
# Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt
# ist, ist dieses Item die Wurzel des Shares beim Empfaenger.
parent_shared = any(
p.file_id == f.parent_id for p in shared_perms
) if f.parent_id else False
if parent_shared:
continue
e = _entry(f)
owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None
if owner:
e['name'] = f'{f.name} (von {owner})'
if f.is_folder:
e['children'] = _build_shared_children(f.id)
shared_roots.append(e)
return jsonify({
'tree': _build_tree(None),
'shared': shared_roots,
}), 200
@api_bp.route('/sync/events', methods=['GET']) @api_bp.route('/sync/events', methods=['GET'])

View File

@ -102,12 +102,63 @@ pub fn install(
ns.set_value("", &display_name.to_string()) ns.set_value("", &display_name.to_string())
.map_err(|e| format!("set ns name: {e}"))?; .map_err(|e| format!("set ns name: {e}"))?;
// 7) Explorer informieren (SHChangeNotify) // 7) Kontext-Menue-Verben (Rechtsklick) fuer Dateien unter dem Mount
install_context_menu(mount_point)?;
// 8) Explorer informieren (SHChangeNotify)
notify_shell(); notify_shell();
Ok(()) Ok(())
} }
/// Registriert "Immer offline halten" / "Speicher freigeben" als
/// Rechtsklick-Menuepunkte, die nur fuer Dateien unterhalb des Mounts
/// angezeigt werden (AppliesTo-Filter).
fn install_context_menu(mount_point: &Path) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let exe = std::env::current_exe()
.map_err(|e| format!("current_exe: {e}"))?
.to_string_lossy()
.into_owned();
let mount_str = mount_point.to_string_lossy();
// AppliesTo filtert die Verben auf Pfade, die unter dem Mount liegen.
// Syntax: "System.ItemPathDisplay:~< \"C:\\Users\\..\\Mini-Cloud\\\""
let applies_to = format!(
"System.ItemPathDisplay:~< \"{}\\\"",
mount_str.replace('\\', "\\\\")
);
for (verb, label, flag) in [
("MiniCloudPin", "Immer offline verfuegbar", "--pin"),
("MiniCloudUnpin", "Speicher freigeben", "--unpin"),
] {
let key_path = format!("Software\\Classes\\*\\shell\\{}", verb);
let (k, _) = hkcu
.create_subkey(&key_path)
.map_err(|e| format!("verb {verb}: {e}"))?;
k.set_value("MUIVerb", &label.to_string())
.map_err(|e| format!("MUIVerb: {e}"))?;
k.set_value("AppliesTo", &applies_to)
.map_err(|e| format!("AppliesTo: {e}"))?;
k.set_value("Icon", &exe)
.map_err(|e| format!("Icon: {e}"))?;
let (cmd, _) = k
.create_subkey("command")
.map_err(|e| format!("cmd: {e}"))?;
cmd.set_value("", &format!("\"{}\" {} \"%1\"", exe, flag))
.map_err(|e| format!("cmdline: {e}"))?;
}
Ok(())
}
fn uninstall_context_menu() {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
for verb in ["MiniCloudPin", "MiniCloudUnpin"] {
let _ = hkcu.delete_subkey_all(format!("Software\\Classes\\*\\shell\\{}", verb));
}
}
/// Entferne die Shell-Integration wieder. /// Entferne die Shell-Integration wieder.
pub fn uninstall() -> Result<(), String> { pub fn uninstall() -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER); let hkcu = RegKey::predef(HKEY_CURRENT_USER);
@ -121,6 +172,8 @@ pub fn uninstall() -> Result<(), String> {
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID); let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
let _ = hkcu.delete_subkey_all(&clsid_path); let _ = hkcu.delete_subkey_all(&clsid_path);
uninstall_context_menu();
notify_shell(); notify_shell();
Ok(()) Ok(())
} }

View File

@ -1030,8 +1030,15 @@ async fn fetch_remote_entries(
.as_array() .as_array()
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let shared = json
.get("shared")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// Rekursiv flach machen (Struktur parent_id beibehalten). // Rekursiv flach machen (Struktur parent_id beibehalten).
// modified_at akzeptiert beides: das neue "modified_at" oder das
// alte "updated_at" als Fallback.
fn walk( fn walk(
nodes: &[serde_json::Value], nodes: &[serde_json::Value],
parent: Option<i64>, parent: Option<i64>,
@ -1049,6 +1056,7 @@ async fn fetch_remote_entries(
let modified_at = n let modified_at = n
.get("modified_at") .get("modified_at")
.and_then(|x| x.as_str()) .and_then(|x| x.as_str())
.or_else(|| n.get("updated_at").and_then(|x| x.as_str()))
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let checksum = n let checksum = n
@ -1071,11 +1079,55 @@ async fn fetch_remote_entries(
} }
let mut flat = Vec::new(); let mut flat = Vec::new();
walk(&tree, None, &mut flat); walk(&tree, None, &mut flat);
// Virtueller Ordner "Geteilt mit mir" nur dann, wenn es geteilte
// Dateien gibt. ID -1 ist reserviert dafuer (keine Kollision
// mit echten DB-IDs).
if !shared.is_empty() {
flat.push(cloud_files::RemoteEntry {
id: -1,
name: "Geteilt mit mir".to_string(),
parent_id: None,
is_folder: true,
size: 0,
modified_at: String::new(),
checksum: None,
});
walk(&shared, Some(-1), &mut flat);
}
Ok(flat) Ok(flat)
} }
/// Short-circuit fuer Shell-Kontextmenue-Aufrufe:
/// `minicloud-sync --pin <file>` oder `--unpin <file>` fuehrt die
/// Aktion direkt aus und beendet. Kein UI, kein Tray.
#[cfg(windows)]
fn handle_cli_shortcuts() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
return;
}
let cmd = args[1].as_str();
let path = std::path::PathBuf::from(&args[2]);
let result = match cmd {
"--pin" => cloud_files::pin_file(&path),
"--unpin" => cloud_files::unpin_file(&path),
_ => return,
};
if let Err(e) = result {
eprintln!("[cloud_files CLI] {cmd} failed: {e}");
std::process::exit(1);
}
std::process::exit(0);
}
#[cfg(not(windows))]
fn handle_cli_shortcuts() {}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
handle_cli_shortcuts();
handle_single_instance(); handle_single_instance();
tauri::Builder::default() tauri::Builder::default()