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'])
|
||||
@token_required
|
||||
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
|
||||
|
||||
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):
|
||||
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()
|
||||
result = []
|
||||
for f in files:
|
||||
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,
|
||||
}
|
||||
lock = FileLock.get_lock(f.id)
|
||||
if lock:
|
||||
entry['locked'] = True
|
||||
entry['locked_by'] = lock.user.username
|
||||
e = _entry(f)
|
||||
if f.is_folder:
|
||||
entry['children'] = _build_tree(f.id)
|
||||
result.append(entry)
|
||||
e['children'] = _build_tree(f.id)
|
||||
result.append(e)
|
||||
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'])
|
||||
|
|
|
|||
|
|
@ -102,12 +102,63 @@ pub fn install(
|
|||
ns.set_value("", &display_name.to_string())
|
||||
.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();
|
||||
|
||||
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.
|
||||
pub fn uninstall() -> Result<(), String> {
|
||||
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 _ = hkcu.delete_subkey_all(&clsid_path);
|
||||
|
||||
uninstall_context_menu();
|
||||
|
||||
notify_shell();
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1030,8 +1030,15 @@ async fn fetch_remote_entries(
|
|||
.as_array()
|
||||
.cloned()
|
||||
.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).
|
||||
// modified_at akzeptiert beides: das neue "modified_at" oder das
|
||||
// alte "updated_at" als Fallback.
|
||||
fn walk(
|
||||
nodes: &[serde_json::Value],
|
||||
parent: Option<i64>,
|
||||
|
|
@ -1049,6 +1056,7 @@ async fn fetch_remote_entries(
|
|||
let modified_at = n
|
||||
.get("modified_at")
|
||||
.and_then(|x| x.as_str())
|
||||
.or_else(|| n.get("updated_at").and_then(|x| x.as_str()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let checksum = n
|
||||
|
|
@ -1071,11 +1079,55 @@ async fn fetch_remote_entries(
|
|||
}
|
||||
let mut flat = Vec::new();
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub fn run() {
|
||||
handle_cli_shortcuts();
|
||||
handle_single_instance();
|
||||
|
||||
tauri::Builder::default()
|
||||
|
|
|
|||
Loading…
Reference in New Issue