1use std::rc::Rc;
8
9use glib::clone;
10use gtk::prelude::*;
11use libadwaita::prelude::*;
12
13use crate::library::state::LibrarySource;
14
15use super::LibraryWindowUi;
16
17pub(super) fn refresh_album_link_row(ui: &LibraryWindowUi, source: &LibrarySource) {
18 let name = match source {
19 LibrarySource::Album { name, .. }
20 | LibrarySource::AlbumLocal { name, .. }
21 | LibrarySource::AlbumUnified { name, .. } => name,
22 _ => {
23 ui.album_link_row.set_visible(false);
24 if let Some(parent) = ui.album_link_row.parent() {
25 parent.set_visible(false);
26 }
27 return;
28 }
29 };
30
31 ui.album_link_row.set_visible(true);
32 if let Some(parent) = ui.album_link_row.parent() {
33 parent.set_visible(true);
34 }
35
36 let entries = ui.ctx.live_watch_paths.lock().clone();
37 match crate::config::watch_entry_for_album(name, &entries) {
38 Some(entry) => {
39 ui.album_link_row.set_title("Linked folder");
40 ui.album_link_row
41 .set_subtitle(&crate::watch_path_display::display_watch_path_inline(
42 entry.path(),
43 ));
44 ui.album_link_button.set_label("Unlink");
45 ui.album_sync_button.set_visible(true);
46 }
47 None => {
48 ui.album_link_row.set_title("No local folder linked");
49 ui.album_link_row
50 .set_subtitle("Drop files in the linked folder to sync this album");
51 ui.album_link_button.set_label("Link folder\u{2026}");
52 ui.album_sync_button.set_visible(false);
53 }
54 }
55}
56
57pub(super) fn connect_album_link_row(ui: Rc<LibraryWindowUi>, _listbox: gtk::ListBox) {
58 ui.album_link_button.connect_clicked(clone!(
59 #[strong]
60 ui,
61 move |_| handle_album_link_click(ui.clone())
62 ));
63 ui.album_sync_button.connect_clicked(clone!(
64 #[strong]
65 ui,
66 move |_| handle_album_sync_click(ui.clone())
67 ));
68}
69
70fn handle_album_sync_click(ui: Rc<LibraryWindowUi>) {
71 let source = ui.ctx.library_state.lock().source.clone();
72 let LibrarySource::Album {
73 id: album_id,
74 name: album_name,
75 } = source
76 else {
77 return;
78 };
79 let entries = ui.ctx.live_watch_paths.lock().clone();
80 let Some(entry) = crate::config::watch_entry_for_album(&album_name, &entries) else {
81 return;
82 };
83 let watch_path = std::path::PathBuf::from(entry.path());
84 let rules = crate::config::FolderRules {
89 delete_folder_to_album: true,
90 delete_album_to_folder: true,
91 sync_method: crate::config::FolderSyncMethod::Full,
92 ..entry.rules()
93 };
94
95 let ui_for_async = ui.clone();
96 glib::MainContext::default().spawn_local(async move {
97 let diff = match crate::library::album_sync::diff_album_vs_folder(
98 ui_for_async.ctx.clone(),
99 &album_id,
100 &watch_path,
101 &rules,
102 true,
103 )
104 .await
105 {
106 Ok(d) => d,
107 Err(err) => {
108 log::error!("Album diff failed: {}", err);
109 return;
110 }
111 };
112 present_sync_dialog(ui_for_async, album_id, album_name, watch_path, diff);
113 });
114}
115
116fn present_sync_dialog(
117 ui: Rc<LibraryWindowUi>,
118 album_id: String,
119 album_name: String,
120 watch_path: std::path::PathBuf,
121 diff: crate::library::album_sync::AlbumDiff,
122) {
123 let upload_count = diff.to_upload.len();
124 let download_count = diff.to_download.len();
125 let remote_delete_count = diff.to_delete_remote.len();
126 let local_delete_count = diff.to_delete_local.len();
127
128 if upload_count == 0
129 && download_count == 0
130 && remote_delete_count == 0
131 && local_delete_count == 0
132 {
133 let msg = if diff.remote_unhashed > 0 {
134 format!(
135 "Already in sync. ({} remote item(s) couldn't be matched — missing checksum.)",
136 diff.remote_unhashed
137 )
138 } else {
139 "Already in sync.".to_string()
140 };
141 let info = libadwaita::AlertDialog::builder()
142 .heading("Album sync")
143 .body(msg)
144 .build();
145 info.add_response("ok", "OK");
146 info.set_default_response(Some("ok"));
147 info.set_close_response("ok");
148 info.present(Some(&ui.window));
149 return;
150 }
151
152 let dialog = libadwaita::AlertDialog::builder()
153 .heading("Sync album")
154 .body(format!(
155 "Pick which directions to apply.{}",
156 if diff.remote_unhashed > 0 {
157 format!(
158 "\n\n{} remote item(s) couldn't be matched (missing checksum).",
159 diff.remote_unhashed
160 )
161 } else {
162 String::new()
163 }
164 ))
165 .build();
166
167 let body_box = gtk::Box::builder()
168 .orientation(gtk::Orientation::Vertical)
169 .spacing(6)
170 .build();
171 let upload_check = gtk::CheckButton::builder()
172 .label(format!("Upload {} item(s) to album", upload_count))
173 .active(upload_count > 0)
174 .sensitive(upload_count > 0)
175 .build();
176 let download_check = gtk::CheckButton::builder()
177 .label(format!("Download {} item(s) to folder", download_count))
178 .active(download_count > 0)
179 .sensitive(download_count > 0)
180 .build();
181 let remote_delete_check = gtk::CheckButton::builder()
182 .label(format!(
183 "Move {} album item(s) to trash",
184 remote_delete_count
185 ))
186 .active(remote_delete_count > 0)
187 .sensitive(remote_delete_count > 0)
188 .build();
189 let local_delete_check = gtk::CheckButton::builder()
190 .label(format!(
191 "Move {} local item(s) to trash",
192 local_delete_count
193 ))
194 .active(local_delete_count > 0)
195 .sensitive(local_delete_count > 0)
196 .build();
197 if upload_count > 0 {
198 body_box.append(&upload_check);
199 }
200 if download_count > 0 {
201 body_box.append(&download_check);
202 }
203 if remote_delete_count > 0 {
204 body_box.append(&remote_delete_check);
205 }
206 if local_delete_count > 0 {
207 body_box.append(&local_delete_check);
208 }
209 dialog.set_extra_child(Some(&body_box));
210
211 dialog.add_response("cancel", "Cancel");
212 dialog.add_response("apply", "Apply");
213 dialog.set_response_appearance("apply", libadwaita::ResponseAppearance::Suggested);
214 dialog.set_default_response(Some("apply"));
215 dialog.set_close_response("cancel");
216
217 let ui_for_apply = ui.clone();
218 dialog.connect_response(None, move |dlg, response| {
219 if response != "apply" {
220 return;
221 }
222 let do_upload = upload_check.is_active();
223 let do_download = download_check.is_active();
224 let do_remote_delete = remote_delete_check.is_active();
225 let do_local_delete = local_delete_check.is_active();
226 if !do_upload && !do_download && !do_remote_delete && !do_local_delete {
227 dlg.close();
228 return;
229 }
230 let ui = ui_for_apply.clone();
231 let album_id = album_id.clone();
232 let album_name = album_name.clone();
233 let watch_path = watch_path.clone();
234 let filtered = crate::library::album_sync::AlbumDiff {
235 to_upload: if do_upload {
236 diff.to_upload.clone()
237 } else {
238 Vec::new()
239 },
240 to_download: if do_download {
241 diff.to_download.clone()
242 } else {
243 Vec::new()
244 },
245 to_delete_remote: if do_remote_delete {
246 diff.to_delete_remote.clone()
247 } else {
248 Vec::new()
249 },
250 to_delete_local: if do_local_delete {
251 diff.to_delete_local.clone()
252 } else {
253 Vec::new()
254 },
255 remote_unhashed: 0,
256 };
257 glib::MainContext::default().spawn_local(execute_sync_selections(
258 ui, album_id, album_name, watch_path, filtered,
259 ));
260 dlg.close();
261 });
262 dialog.present(Some(&ui.window));
263}
264
265async fn execute_sync_selections(
266 ui: Rc<LibraryWindowUi>,
267 album_id: String,
268 album_name: String,
269 watch_path: std::path::PathBuf,
270 diff: crate::library::album_sync::AlbumDiff,
271) {
272 let queued =
273 execute_selected_uploads(&ui, &album_id, &album_name, &watch_path, diff.to_upload).await;
274 let (downloaded, failed) =
275 execute_selected_downloads(&ui, &album_id, &album_name, watch_path, diff.to_download).await;
276 let remote_deleted =
277 execute_selected_remote_deletes(&ui, &album_id, diff.to_delete_remote).await;
278 let (local_deleted, local_delete_failed) =
279 execute_selected_local_deletes(&ui, diff.to_delete_local).await;
280
281 log::info!(
282 "Album sync done: {} queued for upload, {} downloaded, {} download failures, {} moved to Immich trash, {} local trashed, {} local trash failures",
283 queued,
284 downloaded,
285 failed,
286 remote_deleted,
287 local_deleted,
288 local_delete_failed
289 );
290 if queued > 0 || downloaded > 0 || remote_deleted > 0 || local_deleted > 0 {
291 super::refresh_library_after_mutation(ui.clone(), true);
292 }
293}
294
295async fn execute_selected_uploads(
296 ui: &Rc<LibraryWindowUi>,
297 album_id: &str,
298 album_name: &str,
299 watch_path: &std::path::Path,
300 assets: Vec<crate::library::album_sync::LocalEntry>,
301) -> usize {
302 if assets.is_empty() {
303 return 0;
304 }
305 crate::library::album_sync::execute_uploads(
306 ui.ctx.clone(),
307 album_id.to_string(),
308 album_name.to_string(),
309 watch_path.to_path_buf(),
310 assets,
311 )
312 .await
313}
314
315async fn execute_selected_downloads(
316 ui: &Rc<LibraryWindowUi>,
317 album_id: &str,
318 album_name: &str,
319 watch_path: std::path::PathBuf,
320 assets: Vec<crate::api_client::LibraryAsset>,
321) -> (usize, usize) {
322 if assets.is_empty() {
323 return (0, 0);
324 }
325 crate::library::album_sync::execute_downloads(
326 ui.ctx.clone(),
327 watch_path,
328 Some(album_id.to_string()),
329 Some(album_name.to_string()),
330 assets,
331 )
332 .await
333}
334
335async fn execute_selected_remote_deletes(
336 ui: &Rc<LibraryWindowUi>,
337 album_id: &str,
338 assets: Vec<crate::api_client::LibraryAsset>,
339) -> usize {
340 if assets.is_empty() {
341 return 0;
342 }
343 crate::library::album_sync::execute_remote_deletions(ui.ctx.clone(), album_id, assets).await
344}
345
346async fn execute_selected_local_deletes(
347 ui: &Rc<LibraryWindowUi>,
348 assets: Vec<crate::library::album_sync::LocalEntry>,
349) -> (usize, usize) {
350 if assets.is_empty() {
351 return (0, 0);
352 }
353 crate::library::album_sync::execute_local_deletions(ui.ctx.clone(), assets).await
354}
355
356fn handle_album_link_click(ui: Rc<LibraryWindowUi>) {
357 let source = ui.ctx.library_state.lock().source.clone();
358 let LibrarySource::Album {
359 id: album_id,
360 name: album_name,
361 } = source
362 else {
363 return;
364 };
365
366 let entries = ui.ctx.live_watch_paths.lock().clone();
367 let already_linked = crate::config::watch_entry_for_album(&album_name, &entries).is_some();
368
369 if already_linked {
370 unlink_album(ui.clone(), &album_name);
371 return;
372 }
373
374 let dialog = gtk::FileDialog::builder()
375 .title(format!("Link folder for album '{}'", album_name))
376 .build();
377 let ui_for_pick = ui.clone();
378 let album_name_for_pick = album_name.clone();
379 let album_id_for_pick = album_id.clone();
380 dialog.select_folder(Some(&ui.window), gtk::gio::Cancellable::NONE, move |res| {
381 let Ok(folder) = res else { return };
382 let Some(path) = folder.path() else { return };
383 link_album_to_path(
384 ui_for_pick.clone(),
385 album_id_for_pick.clone(),
386 album_name_for_pick.clone(),
387 path,
388 );
389 });
390}
391
392fn unlink_album(ui: Rc<LibraryWindowUi>, album_name: &str) {
393 {
394 let mut config = ui.ctx.config.write();
395 config
396 .data
397 .watch_paths
398 .retain(|entry| entry.album_name() != Some(album_name));
399 if !config.save() {
400 log::error!("Failed to save config after unlink");
401 return;
402 }
403 *ui.ctx.live_watch_paths.lock() = config.data.watch_paths.clone();
404 }
405 let source_after = ui.ctx.library_state.lock().source.clone();
406 refresh_album_link_row(&ui, &source_after);
407}
408
409fn link_album_to_path(
410 ui: Rc<LibraryWindowUi>,
411 album_id: String,
412 album_name: String,
413 path: std::path::PathBuf,
414) {
415 let path_string = path.to_string_lossy().to_string();
416 {
417 let mut config = ui.ctx.config.write();
418 config
419 .data
420 .watch_paths
421 .retain(|entry| entry.album_name() != Some(album_name.as_str()));
422 config
423 .data
424 .watch_paths
425 .push(crate::config::WatchPathEntry::WithConfig {
426 path: path_string,
427 album_id: Some(album_id),
428 album_name: Some(album_name),
429 rules: crate::config::FolderRules::default(),
430 });
431 if !config.save() {
432 log::error!("Failed to save config after link");
433 return;
434 }
435 *ui.ctx.live_watch_paths.lock() = config.data.watch_paths.clone();
436 }
437 let source_after = ui.ctx.library_state.lock().source.clone();
438 refresh_album_link_row(&ui, &source_after);
439}