Skip to main content

mimick/library/
album_link.rs

1//! Album-folder linking, sync dialog, and link/unlink actions.
2//!
3//! Presents a dialog to associate a local watch folder with a remote
4//! Immich album for bidirectional synchronization. Handles link state
5//! persistence and triggers the initial diff when a new link is created.
6
7use 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    // Sync from the album view should preview every operation regardless of
85    // per-folder gates — the user gets explicit checkboxes for each direction
86    // in the confirmation dialog and can opt in even when the folder's rules
87    // would otherwise suppress them.
88    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}