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:
parent
4026defe79
commit
78cfbf1ad3
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue